diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b9f8985c..1d03ce21 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,18 +6,18 @@ }, "metadata": { "description": "Cloud-backed persistent shared memory for AI agents powered by Deeplake", - "version": "0.7.93" + "version": "0.7.99" }, "plugins": [ { "name": "hivemind", "description": "Persistent shared memory powered by Deeplake — captures all session activity and provides cross-session, cross-agent memory search", - "version": "0.7.93", + "version": "0.7.99", "source": { "source": "git-subdir", "url": "https://github.com/activeloopai/hivemind.git", "path": "claude-code", - "sha": "4f2771c879c32184e3f9ca070c12636a24cc7351" + "sha": "96d1579bb80c2e2a8a7e90c2cecfb9eb4d78e8af" }, "homepage": "https://github.com/activeloopai/hivemind" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4572c8f1..98fa37a9 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hivemind", "description": "Cloud-backed persistent memory powered by Deeplake — read, write, and share memory across Claude Code sessions and agents", - "version": "0.7.93", + "version": "0.7.99", "author": { "name": "Activeloop", "url": "https://deeplake.ai" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c03109ea..75975709 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -116,11 +116,11 @@ jobs: - name: Build (typecheck + emit bundle artefacts) # `build` runs `tsc && esbuild`, which is a strict superset of # `typecheck` (the bare `tsc --noEmit` we used to run here). It - # ALSO produces the per-agent bundles AND `openclaw/dist/`. The + # ALSO produces the per-agent bundles AND `harnesses/openclaw/dist/`. The # latter is gitignored, so it doesn't exist after a fresh # `actions/checkout` — and several bundle-scan tests under - # `claude-code/tests/skillify-session-start-injection.test.ts` read - # `openclaw/dist/index.js` and `openclaw/dist/skillify-worker.js` + # `harnesses/claude-code/tests/skillify-session-start-injection.test.ts` read + # `harnesses/openclaw/dist/index.js` and `harnesses/openclaw/dist/skillify-worker.js` # directly. Without this rebuild they fail with ENOENT (see PR #98 # — first CI run after the openclaw skillify wiring landed). run: npm run build @@ -294,19 +294,19 @@ jobs: set -e for f in \ bundle/cli.js \ - claude-code/bundle/capture.js \ - claude-code/bundle/session-start.js \ - claude-code/bundle/session-end.js \ - claude-code/bundle/plugin-cache-gc.js \ - claude-code/bundle/skillify-worker.js \ - codex/bundle/capture.js \ - codex/bundle/session-start.js \ - cursor/bundle/capture.js \ - cursor/bundle/session-start.js \ - hermes/bundle/capture.js \ - hermes/bundle/session-start.js \ + harnesses/claude-code/bundle/capture.js \ + harnesses/claude-code/bundle/session-start.js \ + harnesses/claude-code/bundle/session-end.js \ + harnesses/claude-code/bundle/plugin-cache-gc.js \ + harnesses/claude-code/bundle/skillify-worker.js \ + harnesses/codex/bundle/capture.js \ + harnesses/codex/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 \ - pi/bundle/skillify-worker.js; do + harnesses/pi/bundle/skillify-worker.js; do if [ ! -f "$f" ]; then echo "::error::expected bundle entrypoint missing: $f" exit 1 @@ -417,19 +417,19 @@ jobs: set -e for f in \ bundle/cli.js \ - claude-code/bundle/capture.js \ - claude-code/bundle/session-start.js \ - claude-code/bundle/session-end.js \ - claude-code/bundle/plugin-cache-gc.js \ - claude-code/bundle/skillify-worker.js \ - codex/bundle/capture.js \ - codex/bundle/session-start.js \ - cursor/bundle/capture.js \ - cursor/bundle/session-start.js \ - hermes/bundle/capture.js \ - hermes/bundle/session-start.js \ + harnesses/claude-code/bundle/capture.js \ + harnesses/claude-code/bundle/session-start.js \ + harnesses/claude-code/bundle/session-end.js \ + harnesses/claude-code/bundle/plugin-cache-gc.js \ + harnesses/claude-code/bundle/skillify-worker.js \ + harnesses/codex/bundle/capture.js \ + harnesses/codex/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 \ - pi/bundle/skillify-worker.js; do + harnesses/pi/bundle/skillify-worker.js; do if [ ! -f "$f" ]; then echo "::error::expected bundle entrypoint missing: $f" exit 1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 529df4dd..36532b4c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -114,12 +114,12 @@ jobs: git add -A git add -f \ bundle \ - claude-code/bundle \ - codex/bundle \ - cursor/bundle \ - hermes/bundle \ + harnesses/claude-code/bundle \ + harnesses/codex/bundle \ + harnesses/cursor/bundle \ + harnesses/hermes/bundle \ mcp/bundle \ - pi/bundle + harnesses/pi/bundle git commit -m "release: v$VERSION" RELEASE_SHA=$(git rev-parse HEAD) echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" @@ -133,17 +133,17 @@ jobs: # them on disk for npm publish). git rm --cached -r \ bundle \ - claude-code/bundle \ - codex/bundle \ - cursor/bundle \ - hermes/bundle \ + harnesses/claude-code/bundle \ + harnesses/codex/bundle \ + harnesses/cursor/bundle \ + harnesses/hermes/bundle \ mcp/bundle \ - pi/bundle + harnesses/pi/bundle # Update marketplace.json's git-subdir source.sha to the release # commit. Users doing `claude plugin marketplace add activeloopai/hivemind` # clone main (HEAD = this cleanup commit), read marketplace.json, see # the sha → clone activeloopai/hivemind at that sha (which DOES have - # bundles tracked) → extract `claude-code/` → install. + # bundles tracked) → extract `harnesses/claude-code/` → install. node -e " const fs = require('fs'); const path = '.claude-plugin/marketplace.json'; @@ -263,9 +263,9 @@ jobs: - name: Build bundles # Must run BEFORE the quality gate. `npm run ci` includes vitest, # and the bundle-scan tests under - # claude-code/tests/skillify-session-start-injection.test.ts read - # openclaw/dist/index.js + openclaw/dist/skillify-worker.js - # directly. openclaw/dist/ is gitignored — it only exists after + # harnesses/claude-code/tests/skillify-session-start-injection.test.ts read + # harnesses/openclaw/dist/index.js + harnesses/openclaw/dist/skillify-worker.js + # directly. harnesses/openclaw/dist/ is gitignored — it only exists after # `npm run build`. Without this step before the gate, vitest # fails with ENOENT and the publish aborts. Same root cause as # 64cac0b in ci.yml; that fix never propagated to this job. @@ -336,8 +336,8 @@ jobs: run: clawhub login --token "$CLAWHUB_TOKEN" --no-browser - name: Publish openclaw bundle to ClawHub - # The release job above already synced openclaw/package.json and - # openclaw/openclaw.plugin.json versions to the bumped root + # The release job above already synced harnesses/openclaw/package.json and + # harnesses/openclaw/openclaw.plugin.json versions to the bumped root # version, so this publishes the same X.Y.Z that npm just got. # # --family code-plugin: the existing `hivemind` package on ClawHub diff --git a/.gitignore b/.gitignore index 43070f6d..37e59ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,10 +24,14 @@ deploy-to-cache.sh # marketplace.json sha-pinned source can resolve to a bundle-containing # commit; the very next commit untracks them so main HEAD stays clean. bundle/ -claude-code/bundle/ -codex/bundle/ -cursor/bundle/ -hermes/bundle/ +harnesses/claude-code/bundle/ +harnesses/codex/bundle/ +harnesses/cursor/bundle/ +harnesses/hermes/bundle/ mcp/bundle/ -pi/bundle/ +harnesses/pi/bundle/ evals/ +.cursor/ +cursor_hivemind_environment_variables_c.md +harnesses/cursor/extension/node_modules/ +harnesses/cursor/extension/dist/ \ No newline at end of file diff --git a/.jscpd.json b/.jscpd.json index 7f22ee1e..d34d5b76 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -10,8 +10,8 @@ "**/*.test.ts", "**/tests/**", "**/fixtures/**", - "**/claude-code/.claude-plugin/**", - "**/codex/.codex-plugin/**", + "**/harnesses/claude-code/.claude-plugin/**", + "**/harnesses/codex/.codex-plugin/**", "**/src/hooks/cursor/wiki-worker.ts", "**/src/hooks/cursor/spawn-wiki-worker.ts", "**/src/hooks/cursor/capture.ts", diff --git a/README.md b/README.md index 8d40d9cf..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 | ✅ | ✅ | @@ -165,14 +165,14 @@ Hivemind runs **alongside** OpenClaw's built-in `memory-core` plugin. It does ** Tell Codex to fetch and follow the install instructions: ``` -Fetch and follow instructions from https://raw.githubusercontent.com/activeloopai/hivemind/main/codex/INSTALL.md +Fetch and follow instructions from https://raw.githubusercontent.com/activeloopai/hivemind/main/harnesses/codex/INSTALL.md ``` Or run the installer script directly: ```bash git clone https://github.com/activeloopai/hivemind.git ~/.codex/hivemind -~/.codex/hivemind/codex/install.sh +~/.codex/hivemind/harnesses/codex/install.sh ``` Restart Codex to activate. @@ -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)**.
@@ -342,12 +375,12 @@ hivemind rules list # latest 10 active hivemind rules edit "" # bumps version hivemind rules done # mark closed -# Cross-agent diagnostic / pi/openclaw fallback +# Cross-agent diagnostic / harnesses/pi/openclaw fallback hivemind context # print the injection block on demand ``` **What's injected at SessionStart** (claude-code, cursor, hermes. Codex is -deliberately excluded to keep its user-visible TUI clean; pi/openclaw +deliberately excluded to keep its user-visible TUI clean; harnesses/pi/openclaw fall back to `hivemind context`): ```text @@ -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 → claude-code/bundle/ + codex/bundle/ + cursor/bundle/ + 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0c482c42..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/` | @@ -20,17 +20,19 @@ hivemind/ │ ├── hooks/codex/ ← Codex hooks │ ├── hooks/cursor/ ← Cursor hooks │ ├── hooks/hermes/ ← Hermes shell hooks -│ ├── hooks/pi/ ← pi wiki-worker (extension lives in pi/extension-source/) +│ ├── hooks/pi/ ← pi wiki-worker (extension lives in harnesses/pi/extension-source/) │ ├── embeddings/ ← nomic embed-daemon + protocol + SQL helpers │ ├── mcp/ ← MCP server (used by Hermes; available to any future MCP-aware client) │ ├── commands/ ← auth, auth-creds, auth-login, session-prune │ └── cli/ ← unified `hivemind install` CLI + per-agent installers -├── claude-code/ ← Claude Code plugin source (marketplace-distributed) -├── codex/ ← Codex plugin build output (npm-distributed) -├── cursor/ ← Cursor plugin build output (npm-distributed) -├── hermes/ ← Hermes plugin build output (npm-distributed) +├── harnesses/claude-code/ ← Claude Code plugin source (marketplace-distributed) +├── harnesses/codex/ ← Codex 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) -├── openclaw/ ← OpenClaw plugin source + build output (ClawHub-distributed) -├── pi/ ← pi extension source (ships raw .ts; pi compiles at load) +├── harnesses/openclaw/ ← OpenClaw plugin source + build output (ClawHub-distributed) +├── harnesses/pi/ ← pi extension source (ships raw .ts; pi compiles at load) └── bundle/ ← unified `hivemind` CLI build output ``` 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 da1b7443..61ce608a 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -3,8 +3,8 @@ import { chmodSync, writeFileSync, readFileSync } from "node:fs"; const esmPackageJson = '{"type":"module"}\n'; const hivemindVersion = JSON.parse(readFileSync("package.json", "utf-8")).version; -const openclawVersion = JSON.parse(readFileSync("openclaw/package.json", "utf-8")).version; -const openclawSkillBody = readFileSync("openclaw/skills/SKILL.md", "utf-8"); +const openclawVersion = JSON.parse(readFileSync("harnesses/openclaw/package.json", "utf-8")).version; +const openclawSkillBody = readFileSync("harnesses/openclaw/skills/SKILL.md", "utf-8"); // Claude Code plugin const ccHooks = [ @@ -54,7 +54,7 @@ await build({ bundle: true, platform: "node", format: "esm", - outdir: "claude-code/bundle", + outdir: "harnesses/claude-code/bundle", external: [ "node:*", "node-liblzma", @@ -82,9 +82,9 @@ await build({ }); for (const h of ccAll) { - chmodSync(`claude-code/bundle/${h.out}.js`, 0o755); + chmodSync(`harnesses/claude-code/bundle/${h.out}.js`, 0o755); } -writeFileSync("claude-code/bundle/package.json", esmPackageJson); +writeFileSync("harnesses/claude-code/bundle/package.json", esmPackageJson); // Codex plugin const codexHooks = [ @@ -122,7 +122,7 @@ await build({ bundle: true, platform: "node", format: "esm", - outdir: "codex/bundle", + outdir: "harnesses/codex/bundle", external: [ "node:*", "node-liblzma", @@ -149,9 +149,9 @@ await build({ }); for (const h of codexAll) { - chmodSync(`codex/bundle/${h.out}.js`, 0o755); + chmodSync(`harnesses/codex/bundle/${h.out}.js`, 0o755); } -writeFileSync("codex/bundle/package.json", esmPackageJson); +writeFileSync("harnesses/codex/bundle/package.json", esmPackageJson); // Cursor plugin (1.7+ hooks API). Same shell + commands as the other agents. const cursorHooks = [ @@ -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). @@ -253,7 +253,7 @@ await build({ bundle: true, platform: "node", format: "esm", - outdir: "hermes/bundle", + outdir: "harnesses/hermes/bundle", external: [ "node:*", "node-liblzma", @@ -280,12 +280,12 @@ await build({ }); for (const h of hermesAll) { - chmodSync(`hermes/bundle/${h.out}.js`, 0o755); + chmodSync(`harnesses/hermes/bundle/${h.out}.js`, 0o755); } // Pi (badlogic/pi-mono) — ships a wiki-worker bundle, a skillify-worker // bundle, and an autopull-worker bundle. The pi extension itself is raw .ts -// at pi/extension-source/hivemind.ts; we don't bundle it because pi's +// at harnesses/pi/extension-source/hivemind.ts; we don't bundle it because pi's // runtime compiles + loads the .ts file directly. Embed daemon reuses the // canonical ~/.hivemind/embed-deps/embed-daemon.js — no per-pi embed // bundle needed. Skillify worker is the same shared module used by @@ -306,7 +306,7 @@ await build({ bundle: true, platform: "node", format: "esm", - outdir: "pi/bundle", + outdir: "harnesses/pi/bundle", external: [ "node:*", "node-liblzma", @@ -321,10 +321,10 @@ await build({ }, }); for (const h of piWorker) { - chmodSync(`pi/bundle/${h.out}.js`, 0o755); + chmodSync(`harnesses/pi/bundle/${h.out}.js`, 0o755); } -writeFileSync("pi/bundle/package.json", esmPackageJson); -writeFileSync("hermes/bundle/package.json", esmPackageJson); +writeFileSync("harnesses/pi/bundle/package.json", esmPackageJson); +writeFileSync("harnesses/hermes/bundle/package.json", esmPackageJson); // OpenClaw plugin bundle. The shared CC/Codex source modules reference a // handful of HIVEMIND_* env vars for dev-only overrides. Those env paths are @@ -333,13 +333,13 @@ writeFileSync("hermes/bundle/package.json", esmPackageJson); // with `undefined` at build time to avoid shipping dead env-read code in the // plugin bundle. await build({ - entryPoints: { index: "openclaw/src/index.ts" }, + entryPoints: { index: "harnesses/openclaw/src/index.ts" }, bundle: true, splitting: true, chunkNames: "chunks/[name]-[hash]", platform: "node", format: "esm", - outdir: "openclaw/dist", + outdir: "harnesses/openclaw/dist", external: ["node:*"], // Guarantee `globalThis.__hivemind_tuning__` exists as an object before any // bundled module's lazy env reads execute. esbuild's `define` rewrites @@ -403,7 +403,7 @@ await build({ }, plugins: [{ // Dead-code elimination for transitively bundled CC/Codex-only features. - // openclaw/src/index.ts imports shared modules from ../../src/ (DeeplakeApi, + // harnesses/openclaw/src/index.ts imports shared modules from ../../../src/ (DeeplakeApi, // grep-core, virtual-table-query, auth device-flow). Several of those // modules also host CC-specific helpers that shell out with execSync — // opening the browser for SSO, nudging claude-plugin-update, spawning the @@ -425,7 +425,7 @@ await build({ }, }], }); -writeFileSync("openclaw/dist/package.json", esmPackageJson); +writeFileSync("harnesses/openclaw/dist/package.json", esmPackageJson); // OpenClaw skillify-worker bundle. Same shared module CC/Codex/Cursor/Hermes/Pi // use; openclaw spawns it from its agent_end hook to mine reusable skills out @@ -436,7 +436,7 @@ writeFileSync("openclaw/dist/package.json", esmPackageJson); // with no stubs. // 2. The main bundle uses code splitting (chunks/), and we don't want the // worker's modules entangled with the gateway's chunk graph. -// Lands at openclaw/dist/skillify-worker.js — install-openclaw.ts already +// Lands at harnesses/openclaw/dist/skillify-worker.js — install-openclaw.ts already // copies the entire dist/ recursively, so it ships to // ~/.openclaw/extensions/hivemind/dist/skillify-worker.js with no other change. await build({ @@ -444,7 +444,7 @@ await build({ bundle: true, platform: "node", format: "esm", - outdir: "openclaw/dist", + outdir: "harnesses/openclaw/dist", external: ["node:*"], // Same banner as the main openclaw bundle — see the comment there for // the rationale. The worker entry itself overwrites this with the @@ -500,7 +500,7 @@ await build({ "process.env.HIVEMIND_STATE_DIR": "globalThis.__hivemind_tuning__.HIVEMIND_STATE_DIR", }, }); -chmodSync("openclaw/dist/skillify-worker.js", 0o755); +chmodSync("harnesses/openclaw/dist/skillify-worker.js", 0o755); // Hivemind MCP server (stdio). Reused by Cline / Roo / Kilo / any MCP-aware // agent. Lives at ~/.hivemind/mcp/server.js after install. diff --git a/claude-code/.claude-plugin/plugin.json b/harnesses/claude-code/.claude-plugin/plugin.json similarity index 95% rename from claude-code/.claude-plugin/plugin.json rename to harnesses/claude-code/.claude-plugin/plugin.json index 4572c8f1..98fa37a9 100644 --- a/claude-code/.claude-plugin/plugin.json +++ b/harnesses/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hivemind", "description": "Cloud-backed persistent memory powered by Deeplake — read, write, and share memory across Claude Code sessions and agents", - "version": "0.7.93", + "version": "0.7.99", "author": { "name": "Activeloop", "url": "https://deeplake.ai" diff --git a/claude-code/commands/login.md b/harnesses/claude-code/commands/login.md similarity index 100% rename from claude-code/commands/login.md rename to harnesses/claude-code/commands/login.md diff --git a/claude-code/commands/update.md b/harnesses/claude-code/commands/update.md similarity index 100% rename from claude-code/commands/update.md rename to harnesses/claude-code/commands/update.md diff --git a/claude-code/hooks/hooks.json b/harnesses/claude-code/hooks/hooks.json similarity index 100% rename from claude-code/hooks/hooks.json rename to harnesses/claude-code/hooks/hooks.json diff --git a/claude-code/skills/hivemind-goals/SKILL.md b/harnesses/claude-code/skills/hivemind-goals/SKILL.md similarity index 100% rename from claude-code/skills/hivemind-goals/SKILL.md rename to harnesses/claude-code/skills/hivemind-goals/SKILL.md diff --git a/claude-code/skills/hivemind-graph/SKILL.md b/harnesses/claude-code/skills/hivemind-graph/SKILL.md similarity index 100% rename from claude-code/skills/hivemind-graph/SKILL.md rename to harnesses/claude-code/skills/hivemind-graph/SKILL.md diff --git a/claude-code/skills/hivemind-memory/SKILL.md b/harnesses/claude-code/skills/hivemind-memory/SKILL.md similarity index 100% rename from claude-code/skills/hivemind-memory/SKILL.md rename to harnesses/claude-code/skills/hivemind-memory/SKILL.md diff --git a/claude-code/tsconfig.json b/harnesses/claude-code/tsconfig.json similarity index 100% rename from claude-code/tsconfig.json rename to harnesses/claude-code/tsconfig.json diff --git a/codex/.codex-plugin/plugin.json b/harnesses/codex/.codex-plugin/plugin.json similarity index 100% rename from codex/.codex-plugin/plugin.json rename to harnesses/codex/.codex-plugin/plugin.json diff --git a/codex/INSTALL.md b/harnesses/codex/INSTALL.md similarity index 100% rename from codex/INSTALL.md rename to harnesses/codex/INSTALL.md diff --git a/codex/commands/login.md b/harnesses/codex/commands/login.md similarity index 100% rename from codex/commands/login.md rename to harnesses/codex/commands/login.md diff --git a/codex/commands/update.md b/harnesses/codex/commands/update.md similarity index 100% rename from codex/commands/update.md rename to harnesses/codex/commands/update.md diff --git a/codex/hooks/hooks.json b/harnesses/codex/hooks/hooks.json similarity index 100% rename from codex/hooks/hooks.json rename to harnesses/codex/hooks/hooks.json diff --git a/codex/install.sh b/harnesses/codex/install.sh similarity index 100% rename from codex/install.sh rename to harnesses/codex/install.sh diff --git a/codex/package.json b/harnesses/codex/package.json similarity index 86% rename from codex/package.json rename to harnesses/codex/package.json index 9792b600..6081f115 100644 --- a/codex/package.json +++ b/harnesses/codex/package.json @@ -1,6 +1,6 @@ { "name": "hivemind-codex", - "version": "0.7.93", + "version": "0.7.99", "description": "Cloud-backed persistent shared memory for OpenAI Codex CLI powered by Deeplake", "type": "module" } diff --git a/codex/skills/deeplake-memory/SKILL.md b/harnesses/codex/skills/deeplake-memory/SKILL.md similarity index 100% rename from codex/skills/deeplake-memory/SKILL.md rename to harnesses/codex/skills/deeplake-memory/SKILL.md diff --git a/codex/skills/hivemind-goals/SKILL.md b/harnesses/codex/skills/hivemind-goals/SKILL.md similarity index 100% rename from codex/skills/hivemind-goals/SKILL.md rename to harnesses/codex/skills/hivemind-goals/SKILL.md diff --git a/codex/skills/hivemind-graph/SKILL.md b/harnesses/codex/skills/hivemind-graph/SKILL.md similarity index 100% rename from codex/skills/hivemind-graph/SKILL.md rename to harnesses/codex/skills/hivemind-graph/SKILL.md diff --git a/harnesses/cursor/extension/.vscodeignore b/harnesses/cursor/extension/.vscodeignore new file mode 100644 index 00000000..6da893c3 --- /dev/null +++ b/harnesses/cursor/extension/.vscodeignore @@ -0,0 +1,9 @@ +.vscode/** +.vscode-test/** +src/** +node_modules/** +**/*.map +**/*.ts +webpack.config.js +tsconfig.json +.gitignore 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/harnesses/cursor/extension/hivemind-cursor-extension-0.1.5.vsix b/harnesses/cursor/extension/hivemind-cursor-extension-0.1.5.vsix new file mode 100644 index 00000000..d023c9c2 Binary files /dev/null and b/harnesses/cursor/extension/hivemind-cursor-extension-0.1.5.vsix differ diff --git a/harnesses/cursor/extension/media/d3.v7.min.js b/harnesses/cursor/extension/media/d3.v7.min.js new file mode 100644 index 00000000..33bb8802 --- /dev/null +++ b/harnesses/cursor/extension/media/d3.v7.min.js @@ -0,0 +1,2 @@ +// https://d3js.org v7.9.0 Copyright 2010-2023 Mike Bostock +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t="undefined"!=typeof globalThis?globalThis:t||self).d3=t.d3||{})}(this,(function(t){"use strict";function n(t,n){return null==t||null==n?NaN:tn?1:t>=n?0:NaN}function e(t,n){return null==t||null==n?NaN:nt?1:n>=t?0:NaN}function r(t){let r,o,a;function u(t,n,e=0,i=t.length){if(e>>1;o(t[r],n)<0?e=r+1:i=r}while(en(t(e),r),a=(n,e)=>t(n)-e):(r=t===n||t===e?t:i,o=t,a=t),{left:u,center:function(t,n,e=0,r=t.length){const i=u(t,n,e,r-1);return i>e&&a(t[i-1],n)>-a(t[i],n)?i-1:i},right:function(t,n,e=0,i=t.length){if(e>>1;o(t[r],n)<=0?e=r+1:i=r}while(e{n(t,e,(r<<=2)+0,(i<<=2)+0,o<<=2),n(t,e,r+1,i+1,o),n(t,e,r+2,i+2,o),n(t,e,r+3,i+3,o)}}));function d(t){return function(n,e,r=e){if(!((e=+e)>=0))throw new RangeError("invalid rx");if(!((r=+r)>=0))throw new RangeError("invalid ry");let{data:i,width:o,height:a}=n;if(!((o=Math.floor(o))>=0))throw new RangeError("invalid width");if(!((a=Math.floor(void 0!==a?a:i.length/o))>=0))throw new RangeError("invalid height");if(!o||!a||!e&&!r)return n;const u=e&&t(e),c=r&&t(r),f=i.slice();return u&&c?(p(u,f,i,o,a),p(u,i,f,o,a),p(u,f,i,o,a),g(c,i,f,o,a),g(c,f,i,o,a),g(c,i,f,o,a)):u?(p(u,i,f,o,a),p(u,f,i,o,a),p(u,i,f,o,a)):c&&(g(c,i,f,o,a),g(c,f,i,o,a),g(c,i,f,o,a)),n}}function p(t,n,e,r,i){for(let o=0,a=r*i;o{if(!((o-=a)>=i))return;let u=t*r[i];const c=a*t;for(let t=i,n=i+c;t{if(!((a-=u)>=o))return;let c=n*i[o];const f=u*n,s=f+u;for(let t=o,n=o+f;t=n&&++e;else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(i=+i)>=i&&++e}return e}function _(t){return 0|t.length}function b(t){return!(t>0)}function m(t){return"object"!=typeof t||"length"in t?t:Array.from(t)}function x(t,n){let e,r=0,i=0,o=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(e=n-i,i+=e/++r,o+=e*(n-i));else{let a=-1;for(let u of t)null!=(u=n(u,++a,t))&&(u=+u)>=u&&(e=u-i,i+=e/++r,o+=e*(u-i))}if(r>1)return o/(r-1)}function w(t,n){const e=x(t,n);return e?Math.sqrt(e):e}function M(t,n){let e,r;if(void 0===n)for(const n of t)null!=n&&(void 0===e?n>=n&&(e=r=n):(e>n&&(e=n),r=o&&(e=r=o):(e>o&&(e=o),r0){for(o=t[--i];i>0&&(n=o,e=t[--i],o=n+e,r=e-(o-n),!r););i>0&&(r<0&&t[i-1]<0||r>0&&t[i-1]>0)&&(e=2*r,n=o+e,e==n-o&&(o=n))}return o}}class InternMap extends Map{constructor(t,n=N){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:n}}),null!=t)for(const[n,e]of t)this.set(n,e)}get(t){return super.get(A(this,t))}has(t){return super.has(A(this,t))}set(t,n){return super.set(S(this,t),n)}delete(t){return super.delete(E(this,t))}}class InternSet extends Set{constructor(t,n=N){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:n}}),null!=t)for(const n of t)this.add(n)}has(t){return super.has(A(this,t))}add(t){return super.add(S(this,t))}delete(t){return super.delete(E(this,t))}}function A({_intern:t,_key:n},e){const r=n(e);return t.has(r)?t.get(r):e}function S({_intern:t,_key:n},e){const r=n(e);return t.has(r)?t.get(r):(t.set(r,e),e)}function E({_intern:t,_key:n},e){const r=n(e);return t.has(r)&&(e=t.get(r),t.delete(r)),e}function N(t){return null!==t&&"object"==typeof t?t.valueOf():t}function k(t){return t}function C(t,...n){return F(t,k,k,n)}function P(t,...n){return F(t,Array.from,k,n)}function z(t,n){for(let e=1,r=n.length;et.pop().map((([n,e])=>[...t,n,e]))));return t}function $(t,n,...e){return F(t,k,n,e)}function D(t,n,...e){return F(t,Array.from,n,e)}function R(t){if(1!==t.length)throw new Error("duplicate key");return t[0]}function F(t,n,e,r){return function t(i,o){if(o>=r.length)return e(i);const a=new InternMap,u=r[o++];let c=-1;for(const t of i){const n=u(t,++c,i),e=a.get(n);e?e.push(t):a.set(n,[t])}for(const[n,e]of a)a.set(n,t(e,o));return n(a)}(t,0)}function q(t,n){return Array.from(n,(n=>t[n]))}function U(t,...n){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");t=Array.from(t);let[e]=n;if(e&&2!==e.length||n.length>1){const r=Uint32Array.from(t,((t,n)=>n));return n.length>1?(n=n.map((n=>t.map(n))),r.sort(((t,e)=>{for(const r of n){const n=O(r[t],r[e]);if(n)return n}}))):(e=t.map(e),r.sort(((t,n)=>O(e[t],e[n])))),q(t,r)}return t.sort(I(e))}function I(t=n){if(t===n)return O;if("function"!=typeof t)throw new TypeError("compare is not a function");return(n,e)=>{const r=t(n,e);return r||0===r?r:(0===t(e,e))-(0===t(n,n))}}function O(t,n){return(null==t||!(t>=t))-(null==n||!(n>=n))||(tn?1:0)}var B=Array.prototype.slice;function Y(t){return()=>t}const L=Math.sqrt(50),j=Math.sqrt(10),H=Math.sqrt(2);function X(t,n,e){const r=(n-t)/Math.max(0,e),i=Math.floor(Math.log10(r)),o=r/Math.pow(10,i),a=o>=L?10:o>=j?5:o>=H?2:1;let u,c,f;return i<0?(f=Math.pow(10,-i)/a,u=Math.round(t*f),c=Math.round(n*f),u/fn&&--c,f=-f):(f=Math.pow(10,i)*a,u=Math.round(t/f),c=Math.round(n/f),u*fn&&--c),c0))return[];if((t=+t)===(n=+n))return[t];const r=n=i))return[];const u=o-i+1,c=new Array(u);if(r)if(a<0)for(let t=0;t0?(t=Math.floor(t/i)*i,n=Math.ceil(n/i)*i):i<0&&(t=Math.ceil(t*i)/i,n=Math.floor(n*i)/i),r=i}}function K(t){return Math.max(1,Math.ceil(Math.log(v(t))/Math.LN2)+1)}function Q(){var t=k,n=M,e=K;function r(r){Array.isArray(r)||(r=Array.from(r));var i,o,a,u=r.length,c=new Array(u);for(i=0;i=h)if(t>=h&&n===M){const t=V(l,h,e);isFinite(t)&&(t>0?h=(Math.floor(h/t)+1)*t:t<0&&(h=(Math.ceil(h*-t)+1)/-t))}else d.pop()}for(var p=d.length,g=0,y=p;d[g]<=l;)++g;for(;d[y-1]>h;)--y;(g||y0?d[i-1]:l,v.x1=i0)for(i=0;i=n)&&(e=n);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(e=i)&&(e=i)}return e}function tt(t,n){let e,r=-1,i=-1;if(void 0===n)for(const n of t)++i,null!=n&&(e=n)&&(e=n,r=i);else for(let o of t)null!=(o=n(o,++i,t))&&(e=o)&&(e=o,r=i);return r}function nt(t,n){let e;if(void 0===n)for(const n of t)null!=n&&(e>n||void 0===e&&n>=n)&&(e=n);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(e>i||void 0===e&&i>=i)&&(e=i)}return e}function et(t,n){let e,r=-1,i=-1;if(void 0===n)for(const n of t)++i,null!=n&&(e>n||void 0===e&&n>=n)&&(e=n,r=i);else for(let o of t)null!=(o=n(o,++i,t))&&(e>o||void 0===e&&o>=o)&&(e=o,r=i);return r}function rt(t,n,e=0,r=1/0,i){if(n=Math.floor(n),e=Math.floor(Math.max(0,e)),r=Math.floor(Math.min(t.length-1,r)),!(e<=n&&n<=r))return t;for(i=void 0===i?O:I(i);r>e;){if(r-e>600){const o=r-e+1,a=n-e+1,u=Math.log(o),c=.5*Math.exp(2*u/3),f=.5*Math.sqrt(u*c*(o-c)/o)*(a-o/2<0?-1:1);rt(t,n,Math.max(e,Math.floor(n-a*c/o+f)),Math.min(r,Math.floor(n+(o-a)*c/o+f)),i)}const o=t[n];let a=e,u=r;for(it(t,e,n),i(t[r],o)>0&&it(t,e,r);a0;)--u}0===i(t[e],o)?it(t,e,u):(++u,it(t,u,r)),u<=n&&(e=u+1),n<=u&&(r=u-1)}return t}function it(t,n,e){const r=t[n];t[n]=t[e],t[e]=r}function ot(t,e=n){let r,i=!1;if(1===e.length){let o;for(const a of t){const t=e(a);(i?n(t,o)>0:0===n(t,t))&&(r=a,o=t,i=!0)}}else for(const n of t)(i?e(n,r)>0:0===e(n,n))&&(r=n,i=!0);return r}function at(t,n,e){if(t=Float64Array.from(function*(t,n){if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(yield n);else{let e=-1;for(let r of t)null!=(r=n(r,++e,t))&&(r=+r)>=r&&(yield r)}}(t,e)),(r=t.length)&&!isNaN(n=+n)){if(n<=0||r<2)return nt(t);if(n>=1)return J(t);var r,i=(r-1)*n,o=Math.floor(i),a=J(rt(t,o).subarray(0,o+1));return a+(nt(t.subarray(o+1))-a)*(i-o)}}function ut(t,n,e=o){if((r=t.length)&&!isNaN(n=+n)){if(n<=0||r<2)return+e(t[0],0,t);if(n>=1)return+e(t[r-1],r-1,t);var r,i=(r-1)*n,a=Math.floor(i),u=+e(t[a],a,t);return u+(+e(t[a+1],a+1,t)-u)*(i-a)}}function ct(t,n,e=o){if(!isNaN(n=+n)){if(r=Float64Array.from(t,((n,r)=>o(e(t[r],r,t)))),n<=0)return et(r);if(n>=1)return tt(r);var r,i=Uint32Array.from(t,((t,n)=>n)),a=r.length-1,u=Math.floor(a*n);return rt(i,u,0,a,((t,n)=>O(r[t],r[n]))),(u=ot(i.subarray(0,u+1),(t=>r[t])))>=0?u:-1}}function ft(t){return Array.from(function*(t){for(const n of t)yield*n}(t))}function st(t,n){return[t,n]}function lt(t,n,e){t=+t,n=+n,e=(i=arguments.length)<2?(n=t,t=0,1):i<3?1:+e;for(var r=-1,i=0|Math.max(0,Math.ceil((n-t)/e)),o=new Array(i);++r+t(n)}function kt(t,n){return n=Math.max(0,t.bandwidth()-2*n)/2,t.round()&&(n=Math.round(n)),e=>+t(e)+n}function Ct(){return!this.__axis}function Pt(t,n){var e=[],r=null,i=null,o=6,a=6,u=3,c="undefined"!=typeof window&&window.devicePixelRatio>1?0:.5,f=t===xt||t===Tt?-1:1,s=t===Tt||t===wt?"x":"y",l=t===xt||t===Mt?St:Et;function h(h){var d=null==r?n.ticks?n.ticks.apply(n,e):n.domain():r,p=null==i?n.tickFormat?n.tickFormat.apply(n,e):mt:i,g=Math.max(o,0)+u,y=n.range(),v=+y[0]+c,_=+y[y.length-1]+c,b=(n.bandwidth?kt:Nt)(n.copy(),c),m=h.selection?h.selection():h,x=m.selectAll(".domain").data([null]),w=m.selectAll(".tick").data(d,n).order(),M=w.exit(),T=w.enter().append("g").attr("class","tick"),A=w.select("line"),S=w.select("text");x=x.merge(x.enter().insert("path",".tick").attr("class","domain").attr("stroke","currentColor")),w=w.merge(T),A=A.merge(T.append("line").attr("stroke","currentColor").attr(s+"2",f*o)),S=S.merge(T.append("text").attr("fill","currentColor").attr(s,f*g).attr("dy",t===xt?"0em":t===Mt?"0.71em":"0.32em")),h!==m&&(x=x.transition(h),w=w.transition(h),A=A.transition(h),S=S.transition(h),M=M.transition(h).attr("opacity",At).attr("transform",(function(t){return isFinite(t=b(t))?l(t+c):this.getAttribute("transform")})),T.attr("opacity",At).attr("transform",(function(t){var n=this.parentNode.__axis;return l((n&&isFinite(n=n(t))?n:b(t))+c)}))),M.remove(),x.attr("d",t===Tt||t===wt?a?"M"+f*a+","+v+"H"+c+"V"+_+"H"+f*a:"M"+c+","+v+"V"+_:a?"M"+v+","+f*a+"V"+c+"H"+_+"V"+f*a:"M"+v+","+c+"H"+_),w.attr("opacity",1).attr("transform",(function(t){return l(b(t)+c)})),A.attr(s+"2",f*o),S.attr(s,f*g).text(p),m.filter(Ct).attr("fill","none").attr("font-size",10).attr("font-family","sans-serif").attr("text-anchor",t===wt?"start":t===Tt?"end":"middle"),m.each((function(){this.__axis=b}))}return h.scale=function(t){return arguments.length?(n=t,h):n},h.ticks=function(){return e=Array.from(arguments),h},h.tickArguments=function(t){return arguments.length?(e=null==t?[]:Array.from(t),h):e.slice()},h.tickValues=function(t){return arguments.length?(r=null==t?null:Array.from(t),h):r&&r.slice()},h.tickFormat=function(t){return arguments.length?(i=t,h):i},h.tickSize=function(t){return arguments.length?(o=a=+t,h):o},h.tickSizeInner=function(t){return arguments.length?(o=+t,h):o},h.tickSizeOuter=function(t){return arguments.length?(a=+t,h):a},h.tickPadding=function(t){return arguments.length?(u=+t,h):u},h.offset=function(t){return arguments.length?(c=+t,h):c},h}var zt={value:()=>{}};function $t(){for(var t,n=0,e=arguments.length,r={};n=0&&(n=t.slice(e+1),t=t.slice(0,e)),t&&!r.hasOwnProperty(t))throw new Error("unknown type: "+t);return{type:t,name:n}}))),a=-1,u=o.length;if(!(arguments.length<2)){if(null!=n&&"function"!=typeof n)throw new Error("invalid callback: "+n);for(;++a0)for(var e,r,i=new Array(e),o=0;o=0&&"xmlns"!==(n=t.slice(0,e))&&(t=t.slice(e+1)),Ut.hasOwnProperty(n)?{space:Ut[n],local:t}:t}function Ot(t){return function(){var n=this.ownerDocument,e=this.namespaceURI;return e===qt&&n.documentElement.namespaceURI===qt?n.createElement(t):n.createElementNS(e,t)}}function Bt(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function Yt(t){var n=It(t);return(n.local?Bt:Ot)(n)}function Lt(){}function jt(t){return null==t?Lt:function(){return this.querySelector(t)}}function Ht(t){return null==t?[]:Array.isArray(t)?t:Array.from(t)}function Xt(){return[]}function Gt(t){return null==t?Xt:function(){return this.querySelectorAll(t)}}function Vt(t){return function(){return this.matches(t)}}function Wt(t){return function(n){return n.matches(t)}}var Zt=Array.prototype.find;function Kt(){return this.firstElementChild}var Qt=Array.prototype.filter;function Jt(){return Array.from(this.children)}function tn(t){return new Array(t.length)}function nn(t,n){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=n}function en(t,n,e,r,i,o){for(var a,u=0,c=n.length,f=o.length;un?1:t>=n?0:NaN}function cn(t){return function(){this.removeAttribute(t)}}function fn(t){return function(){this.removeAttributeNS(t.space,t.local)}}function sn(t,n){return function(){this.setAttribute(t,n)}}function ln(t,n){return function(){this.setAttributeNS(t.space,t.local,n)}}function hn(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttribute(t):this.setAttribute(t,e)}}function dn(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,e)}}function pn(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}function gn(t){return function(){this.style.removeProperty(t)}}function yn(t,n,e){return function(){this.style.setProperty(t,n,e)}}function vn(t,n,e){return function(){var r=n.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,e)}}function _n(t,n){return t.style.getPropertyValue(n)||pn(t).getComputedStyle(t,null).getPropertyValue(n)}function bn(t){return function(){delete this[t]}}function mn(t,n){return function(){this[t]=n}}function xn(t,n){return function(){var e=n.apply(this,arguments);null==e?delete this[t]:this[t]=e}}function wn(t){return t.trim().split(/^|\s+/)}function Mn(t){return t.classList||new Tn(t)}function Tn(t){this._node=t,this._names=wn(t.getAttribute("class")||"")}function An(t,n){for(var e=Mn(t),r=-1,i=n.length;++r=0&&(this._names.splice(n,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};var Gn=[null];function Vn(t,n){this._groups=t,this._parents=n}function Wn(){return new Vn([[document.documentElement]],Gn)}function Zn(t){return"string"==typeof t?new Vn([[document.querySelector(t)]],[document.documentElement]):new Vn([[t]],Gn)}Vn.prototype=Wn.prototype={constructor:Vn,select:function(t){"function"!=typeof t&&(t=jt(t));for(var n=this._groups,e=n.length,r=new Array(e),i=0;i=m&&(m=b+1);!(_=y[m])&&++m=0;)(r=i[o])&&(a&&4^r.compareDocumentPosition(a)&&a.parentNode.insertBefore(r,a),a=r);return this},sort:function(t){function n(n,e){return n&&e?t(n.__data__,e.__data__):!n-!e}t||(t=un);for(var e=this._groups,r=e.length,i=new Array(r),o=0;o1?this.each((null==n?gn:"function"==typeof n?vn:yn)(t,n,null==e?"":e)):_n(this.node(),t)},property:function(t,n){return arguments.length>1?this.each((null==n?bn:"function"==typeof n?xn:mn)(t,n)):this.node()[t]},classed:function(t,n){var e=wn(t+"");if(arguments.length<2){for(var r=Mn(this.node()),i=-1,o=e.length;++i=0&&(n=t.slice(e+1),t=t.slice(0,e)),{type:t,name:n}}))}(t+""),a=o.length;if(!(arguments.length<2)){for(u=n?Ln:Yn,r=0;r()=>t;function fe(t,{sourceEvent:n,subject:e,target:r,identifier:i,active:o,x:a,y:u,dx:c,dy:f,dispatch:s}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:n,enumerable:!0,configurable:!0},subject:{value:e,enumerable:!0,configurable:!0},target:{value:r,enumerable:!0,configurable:!0},identifier:{value:i,enumerable:!0,configurable:!0},active:{value:o,enumerable:!0,configurable:!0},x:{value:a,enumerable:!0,configurable:!0},y:{value:u,enumerable:!0,configurable:!0},dx:{value:c,enumerable:!0,configurable:!0},dy:{value:f,enumerable:!0,configurable:!0},_:{value:s}})}function se(t){return!t.ctrlKey&&!t.button}function le(){return this.parentNode}function he(t,n){return null==n?{x:t.x,y:t.y}:n}function de(){return navigator.maxTouchPoints||"ontouchstart"in this}function pe(t,n,e){t.prototype=n.prototype=e,e.constructor=t}function ge(t,n){var e=Object.create(t.prototype);for(var r in n)e[r]=n[r];return e}function ye(){}fe.prototype.on=function(){var t=this._.on.apply(this._,arguments);return t===this._?this:t};var ve=.7,_e=1/ve,be="\\s*([+-]?\\d+)\\s*",me="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*",xe="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*",we=/^#([0-9a-f]{3,8})$/,Me=new RegExp(`^rgb\\(${be},${be},${be}\\)$`),Te=new RegExp(`^rgb\\(${xe},${xe},${xe}\\)$`),Ae=new RegExp(`^rgba\\(${be},${be},${be},${me}\\)$`),Se=new RegExp(`^rgba\\(${xe},${xe},${xe},${me}\\)$`),Ee=new RegExp(`^hsl\\(${me},${xe},${xe}\\)$`),Ne=new RegExp(`^hsla\\(${me},${xe},${xe},${me}\\)$`),ke={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074};function Ce(){return this.rgb().formatHex()}function Pe(){return this.rgb().formatRgb()}function ze(t){var n,e;return t=(t+"").trim().toLowerCase(),(n=we.exec(t))?(e=n[1].length,n=parseInt(n[1],16),6===e?$e(n):3===e?new qe(n>>8&15|n>>4&240,n>>4&15|240&n,(15&n)<<4|15&n,1):8===e?De(n>>24&255,n>>16&255,n>>8&255,(255&n)/255):4===e?De(n>>12&15|n>>8&240,n>>8&15|n>>4&240,n>>4&15|240&n,((15&n)<<4|15&n)/255):null):(n=Me.exec(t))?new qe(n[1],n[2],n[3],1):(n=Te.exec(t))?new qe(255*n[1]/100,255*n[2]/100,255*n[3]/100,1):(n=Ae.exec(t))?De(n[1],n[2],n[3],n[4]):(n=Se.exec(t))?De(255*n[1]/100,255*n[2]/100,255*n[3]/100,n[4]):(n=Ee.exec(t))?Le(n[1],n[2]/100,n[3]/100,1):(n=Ne.exec(t))?Le(n[1],n[2]/100,n[3]/100,n[4]):ke.hasOwnProperty(t)?$e(ke[t]):"transparent"===t?new qe(NaN,NaN,NaN,0):null}function $e(t){return new qe(t>>16&255,t>>8&255,255&t,1)}function De(t,n,e,r){return r<=0&&(t=n=e=NaN),new qe(t,n,e,r)}function Re(t){return t instanceof ye||(t=ze(t)),t?new qe((t=t.rgb()).r,t.g,t.b,t.opacity):new qe}function Fe(t,n,e,r){return 1===arguments.length?Re(t):new qe(t,n,e,null==r?1:r)}function qe(t,n,e,r){this.r=+t,this.g=+n,this.b=+e,this.opacity=+r}function Ue(){return`#${Ye(this.r)}${Ye(this.g)}${Ye(this.b)}`}function Ie(){const t=Oe(this.opacity);return`${1===t?"rgb(":"rgba("}${Be(this.r)}, ${Be(this.g)}, ${Be(this.b)}${1===t?")":`, ${t})`}`}function Oe(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function Be(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function Ye(t){return((t=Be(t))<16?"0":"")+t.toString(16)}function Le(t,n,e,r){return r<=0?t=n=e=NaN:e<=0||e>=1?t=n=NaN:n<=0&&(t=NaN),new Xe(t,n,e,r)}function je(t){if(t instanceof Xe)return new Xe(t.h,t.s,t.l,t.opacity);if(t instanceof ye||(t=ze(t)),!t)return new Xe;if(t instanceof Xe)return t;var n=(t=t.rgb()).r/255,e=t.g/255,r=t.b/255,i=Math.min(n,e,r),o=Math.max(n,e,r),a=NaN,u=o-i,c=(o+i)/2;return u?(a=n===o?(e-r)/u+6*(e0&&c<1?0:a,new Xe(a,u,c,t.opacity)}function He(t,n,e,r){return 1===arguments.length?je(t):new Xe(t,n,e,null==r?1:r)}function Xe(t,n,e,r){this.h=+t,this.s=+n,this.l=+e,this.opacity=+r}function Ge(t){return(t=(t||0)%360)<0?t+360:t}function Ve(t){return Math.max(0,Math.min(1,t||0))}function We(t,n,e){return 255*(t<60?n+(e-n)*t/60:t<180?e:t<240?n+(e-n)*(240-t)/60:n)}pe(ye,ze,{copy(t){return Object.assign(new this.constructor,this,t)},displayable(){return this.rgb().displayable()},hex:Ce,formatHex:Ce,formatHex8:function(){return this.rgb().formatHex8()},formatHsl:function(){return je(this).formatHsl()},formatRgb:Pe,toString:Pe}),pe(qe,Fe,ge(ye,{brighter(t){return t=null==t?_e:Math.pow(_e,t),new qe(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=null==t?ve:Math.pow(ve,t),new qe(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new qe(Be(this.r),Be(this.g),Be(this.b),Oe(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Ue,formatHex:Ue,formatHex8:function(){return`#${Ye(this.r)}${Ye(this.g)}${Ye(this.b)}${Ye(255*(isNaN(this.opacity)?1:this.opacity))}`},formatRgb:Ie,toString:Ie})),pe(Xe,He,ge(ye,{brighter(t){return t=null==t?_e:Math.pow(_e,t),new Xe(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=null==t?ve:Math.pow(ve,t),new Xe(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+360*(this.h<0),n=isNaN(t)||isNaN(this.s)?0:this.s,e=this.l,r=e+(e<.5?e:1-e)*n,i=2*e-r;return new qe(We(t>=240?t-240:t+120,i,r),We(t,i,r),We(t<120?t+240:t-120,i,r),this.opacity)},clamp(){return new Xe(Ge(this.h),Ve(this.s),Ve(this.l),Oe(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){const t=Oe(this.opacity);return`${1===t?"hsl(":"hsla("}${Ge(this.h)}, ${100*Ve(this.s)}%, ${100*Ve(this.l)}%${1===t?")":`, ${t})`}`}}));const Ze=Math.PI/180,Ke=180/Math.PI,Qe=.96422,Je=1,tr=.82521,nr=4/29,er=6/29,rr=3*er*er,ir=er*er*er;function or(t){if(t instanceof ur)return new ur(t.l,t.a,t.b,t.opacity);if(t instanceof pr)return gr(t);t instanceof qe||(t=Re(t));var n,e,r=lr(t.r),i=lr(t.g),o=lr(t.b),a=cr((.2225045*r+.7168786*i+.0606169*o)/Je);return r===i&&i===o?n=e=a:(n=cr((.4360747*r+.3850649*i+.1430804*o)/Qe),e=cr((.0139322*r+.0971045*i+.7141733*o)/tr)),new ur(116*a-16,500*(n-a),200*(a-e),t.opacity)}function ar(t,n,e,r){return 1===arguments.length?or(t):new ur(t,n,e,null==r?1:r)}function ur(t,n,e,r){this.l=+t,this.a=+n,this.b=+e,this.opacity=+r}function cr(t){return t>ir?Math.pow(t,1/3):t/rr+nr}function fr(t){return t>er?t*t*t:rr*(t-nr)}function sr(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function lr(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function hr(t){if(t instanceof pr)return new pr(t.h,t.c,t.l,t.opacity);if(t instanceof ur||(t=or(t)),0===t.a&&0===t.b)return new pr(NaN,0=1?(e=1,n-1):Math.floor(e*n),i=t[r],o=t[r+1],a=r>0?t[r-1]:2*i-o,u=r()=>t;function Cr(t,n){return function(e){return t+e*n}}function Pr(t,n){var e=n-t;return e?Cr(t,e>180||e<-180?e-360*Math.round(e/360):e):kr(isNaN(t)?n:t)}function zr(t){return 1==(t=+t)?$r:function(n,e){return e-n?function(t,n,e){return t=Math.pow(t,e),n=Math.pow(n,e)-t,e=1/e,function(r){return Math.pow(t+r*n,e)}}(n,e,t):kr(isNaN(n)?e:n)}}function $r(t,n){var e=n-t;return e?Cr(t,e):kr(isNaN(t)?n:t)}var Dr=function t(n){var e=zr(n);function r(t,n){var r=e((t=Fe(t)).r,(n=Fe(n)).r),i=e(t.g,n.g),o=e(t.b,n.b),a=$r(t.opacity,n.opacity);return function(n){return t.r=r(n),t.g=i(n),t.b=o(n),t.opacity=a(n),t+""}}return r.gamma=t,r}(1);function Rr(t){return function(n){var e,r,i=n.length,o=new Array(i),a=new Array(i),u=new Array(i);for(e=0;eo&&(i=n.slice(o,i),u[a]?u[a]+=i:u[++a]=i),(e=e[0])===(r=r[0])?u[a]?u[a]+=r:u[++a]=r:(u[++a]=null,c.push({i:a,x:Yr(e,r)})),o=Hr.lastIndex;return o180?n+=360:n-t>180&&(t+=360),o.push({i:e.push(i(e)+"rotate(",null,r)-2,x:Yr(t,n)})):n&&e.push(i(e)+"rotate("+n+r)}(o.rotate,a.rotate,u,c),function(t,n,e,o){t!==n?o.push({i:e.push(i(e)+"skewX(",null,r)-2,x:Yr(t,n)}):n&&e.push(i(e)+"skewX("+n+r)}(o.skewX,a.skewX,u,c),function(t,n,e,r,o,a){if(t!==e||n!==r){var u=o.push(i(o)+"scale(",null,",",null,")");a.push({i:u-4,x:Yr(t,e)},{i:u-2,x:Yr(n,r)})}else 1===e&&1===r||o.push(i(o)+"scale("+e+","+r+")")}(o.scaleX,o.scaleY,a.scaleX,a.scaleY,u,c),o=a=null,function(t){for(var n,e=-1,r=c.length;++e=0&&n._call.call(void 0,t),n=n._next;--yi}function Ci(){xi=(mi=Mi.now())+wi,yi=vi=0;try{ki()}finally{yi=0,function(){var t,n,e=pi,r=1/0;for(;e;)e._call?(r>e._time&&(r=e._time),t=e,e=e._next):(n=e._next,e._next=null,e=t?t._next=n:pi=n);gi=t,zi(r)}(),xi=0}}function Pi(){var t=Mi.now(),n=t-mi;n>bi&&(wi-=n,mi=t)}function zi(t){yi||(vi&&(vi=clearTimeout(vi)),t-xi>24?(t<1/0&&(vi=setTimeout(Ci,t-Mi.now()-wi)),_i&&(_i=clearInterval(_i))):(_i||(mi=Mi.now(),_i=setInterval(Pi,bi)),yi=1,Ti(Ci)))}function $i(t,n,e){var r=new Ei;return n=null==n?0:+n,r.restart((e=>{r.stop(),t(e+n)}),n,e),r}Ei.prototype=Ni.prototype={constructor:Ei,restart:function(t,n,e){if("function"!=typeof t)throw new TypeError("callback is not a function");e=(null==e?Ai():+e)+(null==n?0:+n),this._next||gi===this||(gi?gi._next=this:pi=this,gi=this),this._call=t,this._time=e,zi()},stop:function(){this._call&&(this._call=null,this._time=1/0,zi())}};var Di=$t("start","end","cancel","interrupt"),Ri=[],Fi=0,qi=1,Ui=2,Ii=3,Oi=4,Bi=5,Yi=6;function Li(t,n,e,r,i,o){var a=t.__transition;if(a){if(e in a)return}else t.__transition={};!function(t,n,e){var r,i=t.__transition;function o(t){e.state=qi,e.timer.restart(a,e.delay,e.time),e.delay<=t&&a(t-e.delay)}function a(o){var f,s,l,h;if(e.state!==qi)return c();for(f in i)if((h=i[f]).name===e.name){if(h.state===Ii)return $i(a);h.state===Oi?(h.state=Yi,h.timer.stop(),h.on.call("interrupt",t,t.__data__,h.index,h.group),delete i[f]):+fFi)throw new Error("too late; already scheduled");return e}function Hi(t,n){var e=Xi(t,n);if(e.state>Ii)throw new Error("too late; already running");return e}function Xi(t,n){var e=t.__transition;if(!e||!(e=e[n]))throw new Error("transition not found");return e}function Gi(t,n){var e,r,i,o=t.__transition,a=!0;if(o){for(i in n=null==n?null:n+"",o)(e=o[i]).name===n?(r=e.state>Ui&&e.state=0&&(t=t.slice(0,n)),!t||"start"===t}))}(n)?ji:Hi;return function(){var a=o(this,t),u=a.on;u!==r&&(i=(r=u).copy()).on(n,e),a.on=i}}(e,t,n))},attr:function(t,n){var e=It(t),r="transform"===e?ni:Ki;return this.attrTween(t,"function"==typeof n?(e.local?ro:eo)(e,r,Zi(this,"attr."+t,n)):null==n?(e.local?Ji:Qi)(e):(e.local?no:to)(e,r,n))},attrTween:function(t,n){var e="attr."+t;if(arguments.length<2)return(e=this.tween(e))&&e._value;if(null==n)return this.tween(e,null);if("function"!=typeof n)throw new Error;var r=It(t);return this.tween(e,(r.local?io:oo)(r,n))},style:function(t,n,e){var r="transform"==(t+="")?ti:Ki;return null==n?this.styleTween(t,function(t,n){var e,r,i;return function(){var o=_n(this,t),a=(this.style.removeProperty(t),_n(this,t));return o===a?null:o===e&&a===r?i:i=n(e=o,r=a)}}(t,r)).on("end.style."+t,lo(t)):"function"==typeof n?this.styleTween(t,function(t,n,e){var r,i,o;return function(){var a=_n(this,t),u=e(this),c=u+"";return null==u&&(this.style.removeProperty(t),c=u=_n(this,t)),a===c?null:a===r&&c===i?o:(i=c,o=n(r=a,u))}}(t,r,Zi(this,"style."+t,n))).each(function(t,n){var e,r,i,o,a="style."+n,u="end."+a;return function(){var c=Hi(this,t),f=c.on,s=null==c.value[a]?o||(o=lo(n)):void 0;f===e&&i===s||(r=(e=f).copy()).on(u,i=s),c.on=r}}(this._id,t)):this.styleTween(t,function(t,n,e){var r,i,o=e+"";return function(){var a=_n(this,t);return a===o?null:a===r?i:i=n(r=a,e)}}(t,r,n),e).on("end.style."+t,null)},styleTween:function(t,n,e){var r="style."+(t+="");if(arguments.length<2)return(r=this.tween(r))&&r._value;if(null==n)return this.tween(r,null);if("function"!=typeof n)throw new Error;return this.tween(r,function(t,n,e){var r,i;function o(){var o=n.apply(this,arguments);return o!==i&&(r=(i=o)&&function(t,n,e){return function(r){this.style.setProperty(t,n.call(this,r),e)}}(t,o,e)),r}return o._value=n,o}(t,n,null==e?"":e))},text:function(t){return this.tween("text","function"==typeof t?function(t){return function(){var n=t(this);this.textContent=null==n?"":n}}(Zi(this,"text",t)):function(t){return function(){this.textContent=t}}(null==t?"":t+""))},textTween:function(t){var n="text";if(arguments.length<1)return(n=this.tween(n))&&n._value;if(null==t)return this.tween(n,null);if("function"!=typeof t)throw new Error;return this.tween(n,function(t){var n,e;function r(){var r=t.apply(this,arguments);return r!==e&&(n=(e=r)&&function(t){return function(n){this.textContent=t.call(this,n)}}(r)),n}return r._value=t,r}(t))},remove:function(){return this.on("end.remove",function(t){return function(){var n=this.parentNode;for(var e in this.__transition)if(+e!==t)return;n&&n.removeChild(this)}}(this._id))},tween:function(t,n){var e=this._id;if(t+="",arguments.length<2){for(var r,i=Xi(this.node(),e).tween,o=0,a=i.length;o()=>t;function Qo(t,{sourceEvent:n,target:e,selection:r,mode:i,dispatch:o}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:n,enumerable:!0,configurable:!0},target:{value:e,enumerable:!0,configurable:!0},selection:{value:r,enumerable:!0,configurable:!0},mode:{value:i,enumerable:!0,configurable:!0},_:{value:o}})}function Jo(t){t.preventDefault(),t.stopImmediatePropagation()}var ta={name:"drag"},na={name:"space"},ea={name:"handle"},ra={name:"center"};const{abs:ia,max:oa,min:aa}=Math;function ua(t){return[+t[0],+t[1]]}function ca(t){return[ua(t[0]),ua(t[1])]}var fa={name:"x",handles:["w","e"].map(va),input:function(t,n){return null==t?null:[[+t[0],n[0][1]],[+t[1],n[1][1]]]},output:function(t){return t&&[t[0][0],t[1][0]]}},sa={name:"y",handles:["n","s"].map(va),input:function(t,n){return null==t?null:[[n[0][0],+t[0]],[n[1][0],+t[1]]]},output:function(t){return t&&[t[0][1],t[1][1]]}},la={name:"xy",handles:["n","w","e","s","nw","ne","sw","se"].map(va),input:function(t){return null==t?null:ca(t)},output:function(t){return t}},ha={overlay:"crosshair",selection:"move",n:"ns-resize",e:"ew-resize",s:"ns-resize",w:"ew-resize",nw:"nwse-resize",ne:"nesw-resize",se:"nwse-resize",sw:"nesw-resize"},da={e:"w",w:"e",nw:"ne",ne:"nw",se:"sw",sw:"se"},pa={n:"s",s:"n",nw:"sw",ne:"se",se:"ne",sw:"nw"},ga={overlay:1,selection:1,n:null,e:1,s:null,w:-1,nw:-1,ne:1,se:1,sw:-1},ya={overlay:1,selection:1,n:-1,e:null,s:1,w:null,nw:-1,ne:-1,se:1,sw:1};function va(t){return{type:t}}function _a(t){return!t.ctrlKey&&!t.button}function ba(){var t=this.ownerSVGElement||this;return t.hasAttribute("viewBox")?[[(t=t.viewBox.baseVal).x,t.y],[t.x+t.width,t.y+t.height]]:[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]}function ma(){return navigator.maxTouchPoints||"ontouchstart"in this}function xa(t){for(;!t.__brush;)if(!(t=t.parentNode))return;return t.__brush}function wa(t){var n,e=ba,r=_a,i=ma,o=!0,a=$t("start","brush","end"),u=6;function c(n){var e=n.property("__brush",g).selectAll(".overlay").data([va("overlay")]);e.enter().append("rect").attr("class","overlay").attr("pointer-events","all").attr("cursor",ha.overlay).merge(e).each((function(){var t=xa(this).extent;Zn(this).attr("x",t[0][0]).attr("y",t[0][1]).attr("width",t[1][0]-t[0][0]).attr("height",t[1][1]-t[0][1])})),n.selectAll(".selection").data([va("selection")]).enter().append("rect").attr("class","selection").attr("cursor",ha.selection).attr("fill","#777").attr("fill-opacity",.3).attr("stroke","#fff").attr("shape-rendering","crispEdges");var r=n.selectAll(".handle").data(t.handles,(function(t){return t.type}));r.exit().remove(),r.enter().append("rect").attr("class",(function(t){return"handle handle--"+t.type})).attr("cursor",(function(t){return ha[t.type]})),n.each(f).attr("fill","none").attr("pointer-events","all").on("mousedown.brush",h).filter(i).on("touchstart.brush",h).on("touchmove.brush",d).on("touchend.brush touchcancel.brush",p).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function f(){var t=Zn(this),n=xa(this).selection;n?(t.selectAll(".selection").style("display",null).attr("x",n[0][0]).attr("y",n[0][1]).attr("width",n[1][0]-n[0][0]).attr("height",n[1][1]-n[0][1]),t.selectAll(".handle").style("display",null).attr("x",(function(t){return"e"===t.type[t.type.length-1]?n[1][0]-u/2:n[0][0]-u/2})).attr("y",(function(t){return"s"===t.type[0]?n[1][1]-u/2:n[0][1]-u/2})).attr("width",(function(t){return"n"===t.type||"s"===t.type?n[1][0]-n[0][0]+u:u})).attr("height",(function(t){return"e"===t.type||"w"===t.type?n[1][1]-n[0][1]+u:u}))):t.selectAll(".selection,.handle").style("display","none").attr("x",null).attr("y",null).attr("width",null).attr("height",null)}function s(t,n,e){var r=t.__brush.emitter;return!r||e&&r.clean?new l(t,n,e):r}function l(t,n,e){this.that=t,this.args=n,this.state=t.__brush,this.active=0,this.clean=e}function h(e){if((!n||e.touches)&&r.apply(this,arguments)){var i,a,u,c,l,h,d,p,g,y,v,_=this,b=e.target.__data__.type,m="selection"===(o&&e.metaKey?b="overlay":b)?ta:o&&e.altKey?ra:ea,x=t===sa?null:ga[b],w=t===fa?null:ya[b],M=xa(_),T=M.extent,A=M.selection,S=T[0][0],E=T[0][1],N=T[1][0],k=T[1][1],C=0,P=0,z=x&&w&&o&&e.shiftKey,$=Array.from(e.touches||[e],(t=>{const n=t.identifier;return(t=ne(t,_)).point0=t.slice(),t.identifier=n,t}));Gi(_);var D=s(_,arguments,!0).beforestart();if("overlay"===b){A&&(g=!0);const n=[$[0],$[1]||$[0]];M.selection=A=[[i=t===sa?S:aa(n[0][0],n[1][0]),u=t===fa?E:aa(n[0][1],n[1][1])],[l=t===sa?N:oa(n[0][0],n[1][0]),d=t===fa?k:oa(n[0][1],n[1][1])]],$.length>1&&I(e)}else i=A[0][0],u=A[0][1],l=A[1][0],d=A[1][1];a=i,c=u,h=l,p=d;var R=Zn(_).attr("pointer-events","none"),F=R.selectAll(".overlay").attr("cursor",ha[b]);if(e.touches)D.moved=U,D.ended=O;else{var q=Zn(e.view).on("mousemove.brush",U,!0).on("mouseup.brush",O,!0);o&&q.on("keydown.brush",(function(t){switch(t.keyCode){case 16:z=x&&w;break;case 18:m===ea&&(x&&(l=h-C*x,i=a+C*x),w&&(d=p-P*w,u=c+P*w),m=ra,I(t));break;case 32:m!==ea&&m!==ra||(x<0?l=h-C:x>0&&(i=a-C),w<0?d=p-P:w>0&&(u=c-P),m=na,F.attr("cursor",ha.selection),I(t));break;default:return}Jo(t)}),!0).on("keyup.brush",(function(t){switch(t.keyCode){case 16:z&&(y=v=z=!1,I(t));break;case 18:m===ra&&(x<0?l=h:x>0&&(i=a),w<0?d=p:w>0&&(u=c),m=ea,I(t));break;case 32:m===na&&(t.altKey?(x&&(l=h-C*x,i=a+C*x),w&&(d=p-P*w,u=c+P*w),m=ra):(x<0?l=h:x>0&&(i=a),w<0?d=p:w>0&&(u=c),m=ea),F.attr("cursor",ha[b]),I(t));break;default:return}Jo(t)}),!0),ae(e.view)}f.call(_),D.start(e,m.name)}function U(t){for(const n of t.changedTouches||[t])for(const t of $)t.identifier===n.identifier&&(t.cur=ne(n,_));if(z&&!y&&!v&&1===$.length){const t=$[0];ia(t.cur[0]-t[0])>ia(t.cur[1]-t[1])?v=!0:y=!0}for(const t of $)t.cur&&(t[0]=t.cur[0],t[1]=t.cur[1]);g=!0,Jo(t),I(t)}function I(t){const n=$[0],e=n.point0;var r;switch(C=n[0]-e[0],P=n[1]-e[1],m){case na:case ta:x&&(C=oa(S-i,aa(N-l,C)),a=i+C,h=l+C),w&&(P=oa(E-u,aa(k-d,P)),c=u+P,p=d+P);break;case ea:$[1]?(x&&(a=oa(S,aa(N,$[0][0])),h=oa(S,aa(N,$[1][0])),x=1),w&&(c=oa(E,aa(k,$[0][1])),p=oa(E,aa(k,$[1][1])),w=1)):(x<0?(C=oa(S-i,aa(N-i,C)),a=i+C,h=l):x>0&&(C=oa(S-l,aa(N-l,C)),a=i,h=l+C),w<0?(P=oa(E-u,aa(k-u,P)),c=u+P,p=d):w>0&&(P=oa(E-d,aa(k-d,P)),c=u,p=d+P));break;case ra:x&&(a=oa(S,aa(N,i-C*x)),h=oa(S,aa(N,l+C*x))),w&&(c=oa(E,aa(k,u-P*w)),p=oa(E,aa(k,d+P*w)))}ht+e))}function za(t,n){var e=0,r=null,i=null,o=null;function a(a){var u,c=a.length,f=new Array(c),s=Pa(0,c),l=new Array(c*c),h=new Array(c),d=0;a=Float64Array.from({length:c*c},n?(t,n)=>a[n%c][n/c|0]:(t,n)=>a[n/c|0][n%c]);for(let n=0;nr(f[t],f[n])));for(const e of s){const r=n;if(t){const t=Pa(1+~c,c).filter((t=>t<0?a[~t*c+e]:a[e*c+t]));i&&t.sort(((t,n)=>i(t<0?-a[~t*c+e]:a[e*c+t],n<0?-a[~n*c+e]:a[e*c+n])));for(const r of t)if(r<0){(l[~r*c+e]||(l[~r*c+e]={source:null,target:null})).target={index:e,startAngle:n,endAngle:n+=a[~r*c+e]*d,value:a[~r*c+e]}}else{(l[e*c+r]||(l[e*c+r]={source:null,target:null})).source={index:e,startAngle:n,endAngle:n+=a[e*c+r]*d,value:a[e*c+r]}}h[e]={index:e,startAngle:r,endAngle:n,value:f[e]}}else{const t=Pa(0,c).filter((t=>a[e*c+t]||a[t*c+e]));i&&t.sort(((t,n)=>i(a[e*c+t],a[e*c+n])));for(const r of t){let t;if(e=0))throw new Error(`invalid digits: ${t}`);if(n>15)return qa;const e=10**n;return function(t){this._+=t[0];for(let n=1,r=t.length;nRa)if(Math.abs(s*u-c*f)>Ra&&i){let h=e-o,d=r-a,p=u*u+c*c,g=h*h+d*d,y=Math.sqrt(p),v=Math.sqrt(l),_=i*Math.tan(($a-Math.acos((p+l-g)/(2*y*v)))/2),b=_/v,m=_/y;Math.abs(b-1)>Ra&&this._append`L${t+b*f},${n+b*s}`,this._append`A${i},${i},0,0,${+(s*h>f*d)},${this._x1=t+m*u},${this._y1=n+m*c}`}else this._append`L${this._x1=t},${this._y1=n}`;else;}arc(t,n,e,r,i,o){if(t=+t,n=+n,o=!!o,(e=+e)<0)throw new Error(`negative radius: ${e}`);let a=e*Math.cos(r),u=e*Math.sin(r),c=t+a,f=n+u,s=1^o,l=o?r-i:i-r;null===this._x1?this._append`M${c},${f}`:(Math.abs(this._x1-c)>Ra||Math.abs(this._y1-f)>Ra)&&this._append`L${c},${f}`,e&&(l<0&&(l=l%Da+Da),l>Fa?this._append`A${e},${e},0,1,${s},${t-a},${n-u}A${e},${e},0,1,${s},${this._x1=c},${this._y1=f}`:l>Ra&&this._append`A${e},${e},0,${+(l>=$a)},${s},${this._x1=t+e*Math.cos(i)},${this._y1=n+e*Math.sin(i)}`)}rect(t,n,e,r){this._append`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}h${e=+e}v${+r}h${-e}Z`}toString(){return this._}};function Ia(){return new Ua}Ia.prototype=Ua.prototype;var Oa=Array.prototype.slice;function Ba(t){return function(){return t}}function Ya(t){return t.source}function La(t){return t.target}function ja(t){return t.radius}function Ha(t){return t.startAngle}function Xa(t){return t.endAngle}function Ga(){return 0}function Va(){return 10}function Wa(t){var n=Ya,e=La,r=ja,i=ja,o=Ha,a=Xa,u=Ga,c=null;function f(){var f,s=n.apply(this,arguments),l=e.apply(this,arguments),h=u.apply(this,arguments)/2,d=Oa.call(arguments),p=+r.apply(this,(d[0]=s,d)),g=o.apply(this,d)-Ea,y=a.apply(this,d)-Ea,v=+i.apply(this,(d[0]=l,d)),_=o.apply(this,d)-Ea,b=a.apply(this,d)-Ea;if(c||(c=f=Ia()),h>Ca&&(Ma(y-g)>2*h+Ca?y>g?(g+=h,y-=h):(g-=h,y+=h):g=y=(g+y)/2,Ma(b-_)>2*h+Ca?b>_?(_+=h,b-=h):(_-=h,b+=h):_=b=(_+b)/2),c.moveTo(p*Ta(g),p*Aa(g)),c.arc(0,0,p,g,y),g!==_||y!==b)if(t){var m=v-+t.apply(this,arguments),x=(_+b)/2;c.quadraticCurveTo(0,0,m*Ta(_),m*Aa(_)),c.lineTo(v*Ta(x),v*Aa(x)),c.lineTo(m*Ta(b),m*Aa(b))}else c.quadraticCurveTo(0,0,v*Ta(_),v*Aa(_)),c.arc(0,0,v,_,b);if(c.quadraticCurveTo(0,0,p*Ta(g),p*Aa(g)),c.closePath(),f)return c=null,f+""||null}return t&&(f.headRadius=function(n){return arguments.length?(t="function"==typeof n?n:Ba(+n),f):t}),f.radius=function(t){return arguments.length?(r=i="function"==typeof t?t:Ba(+t),f):r},f.sourceRadius=function(t){return arguments.length?(r="function"==typeof t?t:Ba(+t),f):r},f.targetRadius=function(t){return arguments.length?(i="function"==typeof t?t:Ba(+t),f):i},f.startAngle=function(t){return arguments.length?(o="function"==typeof t?t:Ba(+t),f):o},f.endAngle=function(t){return arguments.length?(a="function"==typeof t?t:Ba(+t),f):a},f.padAngle=function(t){return arguments.length?(u="function"==typeof t?t:Ba(+t),f):u},f.source=function(t){return arguments.length?(n=t,f):n},f.target=function(t){return arguments.length?(e=t,f):e},f.context=function(t){return arguments.length?(c=null==t?null:t,f):c},f}var Za=Array.prototype.slice;function Ka(t,n){return t-n}var Qa=t=>()=>t;function Ja(t,n){for(var e,r=-1,i=n.length;++rr!=d>r&&e<(h-f)*(r-s)/(d-s)+f&&(i=-i)}return i}function nu(t,n,e){var r,i,o,a;return function(t,n,e){return(n[0]-t[0])*(e[1]-t[1])==(e[0]-t[0])*(n[1]-t[1])}(t,n,e)&&(i=t[r=+(t[0]===n[0])],o=e[r],a=n[r],i<=o&&o<=a||a<=o&&o<=i)}function eu(){}var ru=[[],[[[1,1.5],[.5,1]]],[[[1.5,1],[1,1.5]]],[[[1.5,1],[.5,1]]],[[[1,.5],[1.5,1]]],[[[1,1.5],[.5,1]],[[1,.5],[1.5,1]]],[[[1,.5],[1,1.5]]],[[[1,.5],[.5,1]]],[[[.5,1],[1,.5]]],[[[1,1.5],[1,.5]]],[[[.5,1],[1,.5]],[[1.5,1],[1,1.5]]],[[[1.5,1],[1,.5]]],[[[.5,1],[1.5,1]]],[[[1,1.5],[1.5,1]]],[[[.5,1],[1,1.5]]],[]];function iu(){var t=1,n=1,e=K,r=u;function i(t){var n=e(t);if(Array.isArray(n))n=n.slice().sort(Ka);else{const e=M(t,ou);for(n=G(...Z(e[0],e[1],n),n);n[n.length-1]>=e[1];)n.pop();for(;n[1]o(t,n)))}function o(e,i){const o=null==i?NaN:+i;if(isNaN(o))throw new Error(`invalid value: ${i}`);var u=[],c=[];return function(e,r,i){var o,u,c,f,s,l,h=new Array,d=new Array;o=u=-1,f=au(e[0],r),ru[f<<1].forEach(p);for(;++o=r,ru[s<<2].forEach(p);for(;++o0?u.push([t]):c.push(t)})),c.forEach((function(t){for(var n,e=0,r=u.length;e0&&o0&&a=0&&o>=0))throw new Error("invalid size");return t=r,n=o,i},i.thresholds=function(t){return arguments.length?(e="function"==typeof t?t:Array.isArray(t)?Qa(Za.call(t)):Qa(t),i):e},i.smooth=function(t){return arguments.length?(r=t?u:eu,i):r===u},i}function ou(t){return isFinite(t)?t:NaN}function au(t,n){return null!=t&&+t>=n}function uu(t){return null==t||isNaN(t=+t)?-1/0:t}function cu(t,n,e,r){const i=r-n,o=e-n,a=isFinite(i)||isFinite(o)?i/o:Math.sign(i)/Math.sign(o);return isNaN(a)?t:t+a-.5}function fu(t){return t[0]}function su(t){return t[1]}function lu(){return 1}const hu=134217729,du=33306690738754706e-32;function pu(t,n,e,r,i){let o,a,u,c,f=n[0],s=r[0],l=0,h=0;s>f==s>-f?(o=f,f=n[++l]):(o=s,s=r[++h]);let d=0;if(lf==s>-f?(a=f+o,u=o-(a-f),f=n[++l]):(a=s+o,u=o-(a-s),s=r[++h]),o=a,0!==u&&(i[d++]=u);lf==s>-f?(a=o+f,c=a-o,u=o-(a-c)+(f-c),f=n[++l]):(a=o+s,c=a-o,u=o-(a-c)+(s-c),s=r[++h]),o=a,0!==u&&(i[d++]=u);for(;l=33306690738754716e-32*f?c:-function(t,n,e,r,i,o,a){let u,c,f,s,l,h,d,p,g,y,v,_,b,m,x,w,M,T;const A=t-i,S=e-i,E=n-o,N=r-o;m=A*N,h=hu*A,d=h-(h-A),p=A-d,h=hu*N,g=h-(h-N),y=N-g,x=p*y-(m-d*g-p*g-d*y),w=E*S,h=hu*E,d=h-(h-E),p=E-d,h=hu*S,g=h-(h-S),y=S-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,_u[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,_u[1]=b-(v+l)+(l-w),T=_+v,l=T-_,_u[2]=_-(T-l)+(v-l),_u[3]=T;let k=function(t,n){let e=n[0];for(let r=1;r=C||-k>=C)return k;if(l=t-A,u=t-(A+l)+(l-i),l=e-S,f=e-(S+l)+(l-i),l=n-E,c=n-(E+l)+(l-o),l=r-N,s=r-(N+l)+(l-o),0===u&&0===c&&0===f&&0===s)return k;if(C=vu*a+du*Math.abs(k),k+=A*s+N*u-(E*f+S*c),k>=C||-k>=C)return k;m=u*N,h=hu*u,d=h-(h-u),p=u-d,h=hu*N,g=h-(h-N),y=N-g,x=p*y-(m-d*g-p*g-d*y),w=c*S,h=hu*c,d=h-(h-c),p=c-d,h=hu*S,g=h-(h-S),y=S-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,wu[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,wu[1]=b-(v+l)+(l-w),T=_+v,l=T-_,wu[2]=_-(T-l)+(v-l),wu[3]=T;const P=pu(4,_u,4,wu,bu);m=A*s,h=hu*A,d=h-(h-A),p=A-d,h=hu*s,g=h-(h-s),y=s-g,x=p*y-(m-d*g-p*g-d*y),w=E*f,h=hu*E,d=h-(h-E),p=E-d,h=hu*f,g=h-(h-f),y=f-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,wu[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,wu[1]=b-(v+l)+(l-w),T=_+v,l=T-_,wu[2]=_-(T-l)+(v-l),wu[3]=T;const z=pu(P,bu,4,wu,mu);m=u*s,h=hu*u,d=h-(h-u),p=u-d,h=hu*s,g=h-(h-s),y=s-g,x=p*y-(m-d*g-p*g-d*y),w=c*f,h=hu*c,d=h-(h-c),p=c-d,h=hu*f,g=h-(h-f),y=f-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,wu[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,wu[1]=b-(v+l)+(l-w),T=_+v,l=T-_,wu[2]=_-(T-l)+(v-l),wu[3]=T;const $=pu(z,mu,4,wu,xu);return xu[$-1]}(t,n,e,r,i,o,f)}const Tu=Math.pow(2,-52),Au=new Uint32Array(512);class Su{static from(t,n=zu,e=$u){const r=t.length,i=new Float64Array(2*r);for(let o=0;o>1;if(n>0&&"number"!=typeof t[0])throw new Error("Expected coords to contain numbers.");this.coords=t;const e=Math.max(2*n-5,0);this._triangles=new Uint32Array(3*e),this._halfedges=new Int32Array(3*e),this._hashSize=Math.ceil(Math.sqrt(n)),this._hullPrev=new Uint32Array(n),this._hullNext=new Uint32Array(n),this._hullTri=new Uint32Array(n),this._hullHash=new Int32Array(this._hashSize),this._ids=new Uint32Array(n),this._dists=new Float64Array(n),this.update()}update(){const{coords:t,_hullPrev:n,_hullNext:e,_hullTri:r,_hullHash:i}=this,o=t.length>>1;let a=1/0,u=1/0,c=-1/0,f=-1/0;for(let n=0;nc&&(c=e),r>f&&(f=r),this._ids[n]=n}const s=(a+c)/2,l=(u+f)/2;let h,d,p;for(let n=0,e=1/0;n0&&(d=n,e=r)}let v=t[2*d],_=t[2*d+1],b=1/0;for(let n=0;nr&&(n[e++]=i,r=o)}return this.hull=n.subarray(0,e),this.triangles=new Uint32Array(0),void(this.halfedges=new Uint32Array(0))}if(Mu(g,y,v,_,m,x)<0){const t=d,n=v,e=_;d=p,v=m,_=x,p=t,m=n,x=e}const w=function(t,n,e,r,i,o){const a=e-t,u=r-n,c=i-t,f=o-n,s=a*a+u*u,l=c*c+f*f,h=.5/(a*f-u*c),d=t+(f*s-u*l)*h,p=n+(a*l-c*s)*h;return{x:d,y:p}}(g,y,v,_,m,x);this._cx=w.x,this._cy=w.y;for(let n=0;n0&&Math.abs(f-o)<=Tu&&Math.abs(s-a)<=Tu)continue;if(o=f,a=s,c===h||c===d||c===p)continue;let l=0;for(let t=0,n=this._hashKey(f,s);t=0;)if(y=g,y===l){y=-1;break}if(-1===y)continue;let v=this._addTriangle(y,c,e[y],-1,-1,r[y]);r[c]=this._legalize(v+2),r[y]=v,M++;let _=e[y];for(;g=e[_],Mu(f,s,t[2*_],t[2*_+1],t[2*g],t[2*g+1])<0;)v=this._addTriangle(_,c,g,r[c],-1,r[_]),r[c]=this._legalize(v+2),e[_]=_,M--,_=g;if(y===l)for(;g=n[y],Mu(f,s,t[2*g],t[2*g+1],t[2*y],t[2*y+1])<0;)v=this._addTriangle(g,c,y,-1,r[y],r[g]),this._legalize(v+2),r[g]=v,e[y]=y,M--,y=g;this._hullStart=n[c]=y,e[y]=n[_]=c,e[c]=_,i[this._hashKey(f,s)]=c,i[this._hashKey(t[2*y],t[2*y+1])]=y}this.hull=new Uint32Array(M);for(let t=0,n=this._hullStart;t0?3-e:1+e)/4}(t-this._cx,n-this._cy)*this._hashSize)%this._hashSize}_legalize(t){const{_triangles:n,_halfedges:e,coords:r}=this;let i=0,o=0;for(;;){const a=e[t],u=t-t%3;if(o=u+(t+2)%3,-1===a){if(0===i)break;t=Au[--i];continue}const c=a-a%3,f=u+(t+1)%3,s=c+(a+2)%3,l=n[o],h=n[t],d=n[f],p=n[s];if(Nu(r[2*l],r[2*l+1],r[2*h],r[2*h+1],r[2*d],r[2*d+1],r[2*p],r[2*p+1])){n[t]=p,n[a]=l;const r=e[s];if(-1===r){let n=this._hullStart;do{if(this._hullTri[n]===s){this._hullTri[n]=t;break}n=this._hullPrev[n]}while(n!==this._hullStart)}this._link(t,r),this._link(a,e[o]),this._link(o,s);const u=c+(a+1)%3;i=e&&n[t[a]]>o;)t[a+1]=t[a--];t[a+1]=r}else{let i=e+1,o=r;Pu(t,e+r>>1,i),n[t[e]]>n[t[r]]&&Pu(t,e,r),n[t[i]]>n[t[r]]&&Pu(t,i,r),n[t[e]]>n[t[i]]&&Pu(t,e,i);const a=t[i],u=n[a];for(;;){do{i++}while(n[t[i]]u);if(o=o-e?(Cu(t,n,i,r),Cu(t,n,e,o-1)):(Cu(t,n,e,o-1),Cu(t,n,i,r))}}function Pu(t,n,e){const r=t[n];t[n]=t[e],t[e]=r}function zu(t){return t[0]}function $u(t){return t[1]}const Du=1e-6;class Ru{constructor(){this._x0=this._y0=this._x1=this._y1=null,this._=""}moveTo(t,n){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}`}closePath(){null!==this._x1&&(this._x1=this._x0,this._y1=this._y0,this._+="Z")}lineTo(t,n){this._+=`L${this._x1=+t},${this._y1=+n}`}arc(t,n,e){const r=(t=+t)+(e=+e),i=n=+n;if(e<0)throw new Error("negative radius");null===this._x1?this._+=`M${r},${i}`:(Math.abs(this._x1-r)>Du||Math.abs(this._y1-i)>Du)&&(this._+="L"+r+","+i),e&&(this._+=`A${e},${e},0,1,1,${t-e},${n}A${e},${e},0,1,1,${this._x1=r},${this._y1=i}`)}rect(t,n,e,r){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}h${+e}v${+r}h${-e}Z`}value(){return this._||null}}class Fu{constructor(){this._=[]}moveTo(t,n){this._.push([t,n])}closePath(){this._.push(this._[0].slice())}lineTo(t,n){this._.push([t,n])}value(){return this._.length?this._:null}}class qu{constructor(t,[n,e,r,i]=[0,0,960,500]){if(!((r=+r)>=(n=+n)&&(i=+i)>=(e=+e)))throw new Error("invalid bounds");this.delaunay=t,this._circumcenters=new Float64Array(2*t.points.length),this.vectors=new Float64Array(2*t.points.length),this.xmax=r,this.xmin=n,this.ymax=i,this.ymin=e,this._init()}update(){return this.delaunay.update(),this._init(),this}_init(){const{delaunay:{points:t,hull:n,triangles:e},vectors:r}=this;let i,o;const a=this.circumcenters=this._circumcenters.subarray(0,e.length/3*2);for(let r,u,c=0,f=0,s=e.length;c1;)i-=2;for(let t=2;t0){if(n>=this.ymax)return null;(i=(this.ymax-n)/r)0){if(t>=this.xmax)return null;(i=(this.xmax-t)/e)this.xmax?2:0)|(nthis.ymax?8:0)}_simplify(t){if(t&&t.length>4){for(let n=0;n2&&function(t){const{triangles:n,coords:e}=t;for(let t=0;t1e-10)return!1}return!0}(t)){this.collinear=Int32Array.from({length:n.length/2},((t,n)=>n)).sort(((t,e)=>n[2*t]-n[2*e]||n[2*t+1]-n[2*e+1]));const t=this.collinear[0],e=this.collinear[this.collinear.length-1],r=[n[2*t],n[2*t+1],n[2*e],n[2*e+1]],i=1e-8*Math.hypot(r[3]-r[1],r[2]-r[0]);for(let t=0,e=n.length/2;t0&&(this.triangles=new Int32Array(3).fill(-1),this.halfedges=new Int32Array(3).fill(-1),this.triangles[0]=r[0],o[r[0]]=1,2===r.length&&(o[r[1]]=0,this.triangles[1]=r[1],this.triangles[2]=r[1]))}voronoi(t){return new qu(this,t)}*neighbors(t){const{inedges:n,hull:e,_hullIndex:r,halfedges:i,triangles:o,collinear:a}=this;if(a){const n=a.indexOf(t);return n>0&&(yield a[n-1]),void(n=0&&i!==e&&i!==r;)e=i;return i}_step(t,n,e){const{inedges:r,hull:i,_hullIndex:o,halfedges:a,triangles:u,points:c}=this;if(-1===r[t]||!c.length)return(t+1)%(c.length>>1);let f=t,s=Iu(n-c[2*t],2)+Iu(e-c[2*t+1],2);const l=r[t];let h=l;do{let r=u[h];const l=Iu(n-c[2*r],2)+Iu(e-c[2*r+1],2);if(l9999?"+"+Ku(n,6):Ku(n,4))+"-"+Ku(t.getUTCMonth()+1,2)+"-"+Ku(t.getUTCDate(),2)+(o?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"."+Ku(o,3)+"Z":i?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"Z":r||e?"T"+Ku(e,2)+":"+Ku(r,2)+"Z":"")}function Ju(t){var n=new RegExp('["'+t+"\n\r]"),e=t.charCodeAt(0);function r(t,n){var r,i=[],o=t.length,a=0,u=0,c=o<=0,f=!1;function s(){if(c)return Hu;if(f)return f=!1,ju;var n,r,i=a;if(t.charCodeAt(i)===Xu){for(;a++=o?c=!0:(r=t.charCodeAt(a++))===Gu?f=!0:r===Vu&&(f=!0,t.charCodeAt(a)===Gu&&++a),t.slice(i+1,n-1).replace(/""/g,'"')}for(;amc(n,e).then((n=>(new DOMParser).parseFromString(n,t)))}var Sc=Ac("application/xml"),Ec=Ac("text/html"),Nc=Ac("image/svg+xml");function kc(t,n,e,r){if(isNaN(n)||isNaN(e))return t;var i,o,a,u,c,f,s,l,h,d=t._root,p={data:r},g=t._x0,y=t._y0,v=t._x1,_=t._y1;if(!d)return t._root=p,t;for(;d.length;)if((f=n>=(o=(g+v)/2))?g=o:v=o,(s=e>=(a=(y+_)/2))?y=a:_=a,i=d,!(d=d[l=s<<1|f]))return i[l]=p,t;if(u=+t._x.call(null,d.data),c=+t._y.call(null,d.data),n===u&&e===c)return p.next=d,i?i[l]=p:t._root=p,t;do{i=i?i[l]=new Array(4):t._root=new Array(4),(f=n>=(o=(g+v)/2))?g=o:v=o,(s=e>=(a=(y+_)/2))?y=a:_=a}while((l=s<<1|f)==(h=(c>=a)<<1|u>=o));return i[h]=d,i[l]=p,t}function Cc(t,n,e,r,i){this.node=t,this.x0=n,this.y0=e,this.x1=r,this.y1=i}function Pc(t){return t[0]}function zc(t){return t[1]}function $c(t,n,e){var r=new Dc(null==n?Pc:n,null==e?zc:e,NaN,NaN,NaN,NaN);return null==t?r:r.addAll(t)}function Dc(t,n,e,r,i,o){this._x=t,this._y=n,this._x0=e,this._y0=r,this._x1=i,this._y1=o,this._root=void 0}function Rc(t){for(var n={data:t.data},e=n;t=t.next;)e=e.next={data:t.data};return n}var Fc=$c.prototype=Dc.prototype;function qc(t){return function(){return t}}function Uc(t){return 1e-6*(t()-.5)}function Ic(t){return t.x+t.vx}function Oc(t){return t.y+t.vy}function Bc(t){return t.index}function Yc(t,n){var e=t.get(n);if(!e)throw new Error("node not found: "+n);return e}Fc.copy=function(){var t,n,e=new Dc(this._x,this._y,this._x0,this._y0,this._x1,this._y1),r=this._root;if(!r)return e;if(!r.length)return e._root=Rc(r),e;for(t=[{source:r,target:e._root=new Array(4)}];r=t.pop();)for(var i=0;i<4;++i)(n=r.source[i])&&(n.length?t.push({source:n,target:r.target[i]=new Array(4)}):r.target[i]=Rc(n));return e},Fc.add=function(t){const n=+this._x.call(null,t),e=+this._y.call(null,t);return kc(this.cover(n,e),n,e,t)},Fc.addAll=function(t){var n,e,r,i,o=t.length,a=new Array(o),u=new Array(o),c=1/0,f=1/0,s=-1/0,l=-1/0;for(e=0;es&&(s=r),il&&(l=i));if(c>s||f>l)return this;for(this.cover(c,f).cover(s,l),e=0;et||t>=i||r>n||n>=o;)switch(u=(nh||(o=c.y0)>d||(a=c.x1)=v)<<1|t>=y)&&(c=p[p.length-1],p[p.length-1]=p[p.length-1-f],p[p.length-1-f]=c)}else{var _=t-+this._x.call(null,g.data),b=n-+this._y.call(null,g.data),m=_*_+b*b;if(m=(u=(p+y)/2))?p=u:y=u,(s=a>=(c=(g+v)/2))?g=c:v=c,n=d,!(d=d[l=s<<1|f]))return this;if(!d.length)break;(n[l+1&3]||n[l+2&3]||n[l+3&3])&&(e=n,h=l)}for(;d.data!==t;)if(r=d,!(d=d.next))return this;return(i=d.next)&&delete d.next,r?(i?r.next=i:delete r.next,this):n?(i?n[l]=i:delete n[l],(d=n[0]||n[1]||n[2]||n[3])&&d===(n[3]||n[2]||n[1]||n[0])&&!d.length&&(e?e[h]=d:this._root=d),this):(this._root=i,this)},Fc.removeAll=function(t){for(var n=0,e=t.length;n1?r[0]+r.slice(2):r,+t.slice(e+1)]}function Zc(t){return(t=Wc(Math.abs(t)))?t[1]:NaN}var Kc,Qc=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function Jc(t){if(!(n=Qc.exec(t)))throw new Error("invalid format: "+t);var n;return new tf({fill:n[1],align:n[2],sign:n[3],symbol:n[4],zero:n[5],width:n[6],comma:n[7],precision:n[8]&&n[8].slice(1),trim:n[9],type:n[10]})}function tf(t){this.fill=void 0===t.fill?" ":t.fill+"",this.align=void 0===t.align?">":t.align+"",this.sign=void 0===t.sign?"-":t.sign+"",this.symbol=void 0===t.symbol?"":t.symbol+"",this.zero=!!t.zero,this.width=void 0===t.width?void 0:+t.width,this.comma=!!t.comma,this.precision=void 0===t.precision?void 0:+t.precision,this.trim=!!t.trim,this.type=void 0===t.type?"":t.type+""}function nf(t,n){var e=Wc(t,n);if(!e)return t+"";var r=e[0],i=e[1];return i<0?"0."+new Array(-i).join("0")+r:r.length>i+1?r.slice(0,i+1)+"."+r.slice(i+1):r+new Array(i-r.length+2).join("0")}Jc.prototype=tf.prototype,tf.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(void 0===this.width?"":Math.max(1,0|this.width))+(this.comma?",":"")+(void 0===this.precision?"":"."+Math.max(0,0|this.precision))+(this.trim?"~":"")+this.type};var ef={"%":(t,n)=>(100*t).toFixed(n),b:t=>Math.round(t).toString(2),c:t=>t+"",d:function(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString("en").replace(/,/g,""):t.toString(10)},e:(t,n)=>t.toExponential(n),f:(t,n)=>t.toFixed(n),g:(t,n)=>t.toPrecision(n),o:t=>Math.round(t).toString(8),p:(t,n)=>nf(100*t,n),r:nf,s:function(t,n){var e=Wc(t,n);if(!e)return t+"";var r=e[0],i=e[1],o=i-(Kc=3*Math.max(-8,Math.min(8,Math.floor(i/3))))+1,a=r.length;return o===a?r:o>a?r+new Array(o-a+1).join("0"):o>0?r.slice(0,o)+"."+r.slice(o):"0."+new Array(1-o).join("0")+Wc(t,Math.max(0,n+o-1))[0]},X:t=>Math.round(t).toString(16).toUpperCase(),x:t=>Math.round(t).toString(16)};function rf(t){return t}var of,af=Array.prototype.map,uf=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];function cf(t){var n,e,r=void 0===t.grouping||void 0===t.thousands?rf:(n=af.call(t.grouping,Number),e=t.thousands+"",function(t,r){for(var i=t.length,o=[],a=0,u=n[0],c=0;i>0&&u>0&&(c+u+1>r&&(u=Math.max(1,r-c)),o.push(t.substring(i-=u,i+u)),!((c+=u+1)>r));)u=n[a=(a+1)%n.length];return o.reverse().join(e)}),i=void 0===t.currency?"":t.currency[0]+"",o=void 0===t.currency?"":t.currency[1]+"",a=void 0===t.decimal?".":t.decimal+"",u=void 0===t.numerals?rf:function(t){return function(n){return n.replace(/[0-9]/g,(function(n){return t[+n]}))}}(af.call(t.numerals,String)),c=void 0===t.percent?"%":t.percent+"",f=void 0===t.minus?"−":t.minus+"",s=void 0===t.nan?"NaN":t.nan+"";function l(t){var n=(t=Jc(t)).fill,e=t.align,l=t.sign,h=t.symbol,d=t.zero,p=t.width,g=t.comma,y=t.precision,v=t.trim,_=t.type;"n"===_?(g=!0,_="g"):ef[_]||(void 0===y&&(y=12),v=!0,_="g"),(d||"0"===n&&"="===e)&&(d=!0,n="0",e="=");var b="$"===h?i:"#"===h&&/[boxX]/.test(_)?"0"+_.toLowerCase():"",m="$"===h?o:/[%p]/.test(_)?c:"",x=ef[_],w=/[defgprs%]/.test(_);function M(t){var i,o,c,h=b,M=m;if("c"===_)M=x(t)+M,t="";else{var T=(t=+t)<0||1/t<0;if(t=isNaN(t)?s:x(Math.abs(t),y),v&&(t=function(t){t:for(var n,e=t.length,r=1,i=-1;r0&&(i=0)}return i>0?t.slice(0,i)+t.slice(n+1):t}(t)),T&&0==+t&&"+"!==l&&(T=!1),h=(T?"("===l?l:f:"-"===l||"("===l?"":l)+h,M=("s"===_?uf[8+Kc/3]:"")+M+(T&&"("===l?")":""),w)for(i=-1,o=t.length;++i(c=t.charCodeAt(i))||c>57){M=(46===c?a+t.slice(i+1):t.slice(i))+M,t=t.slice(0,i);break}}g&&!d&&(t=r(t,1/0));var A=h.length+t.length+M.length,S=A>1)+h+t+M+S.slice(A);break;default:t=S+h+t+M}return u(t)}return y=void 0===y?6:/[gprs]/.test(_)?Math.max(1,Math.min(21,y)):Math.max(0,Math.min(20,y)),M.toString=function(){return t+""},M}return{format:l,formatPrefix:function(t,n){var e=l(((t=Jc(t)).type="f",t)),r=3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3))),i=Math.pow(10,-r),o=uf[8+r/3];return function(t){return e(i*t)+o}}}}function ff(n){return of=cf(n),t.format=of.format,t.formatPrefix=of.formatPrefix,of}function sf(t){return Math.max(0,-Zc(Math.abs(t)))}function lf(t,n){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3)))-Zc(Math.abs(t)))}function hf(t,n){return t=Math.abs(t),n=Math.abs(n)-t,Math.max(0,Zc(n)-Zc(t))+1}t.format=void 0,t.formatPrefix=void 0,ff({thousands:",",grouping:[3],currency:["$",""]});var df=1e-6,pf=1e-12,gf=Math.PI,yf=gf/2,vf=gf/4,_f=2*gf,bf=180/gf,mf=gf/180,xf=Math.abs,wf=Math.atan,Mf=Math.atan2,Tf=Math.cos,Af=Math.ceil,Sf=Math.exp,Ef=Math.hypot,Nf=Math.log,kf=Math.pow,Cf=Math.sin,Pf=Math.sign||function(t){return t>0?1:t<0?-1:0},zf=Math.sqrt,$f=Math.tan;function Df(t){return t>1?0:t<-1?gf:Math.acos(t)}function Rf(t){return t>1?yf:t<-1?-yf:Math.asin(t)}function Ff(t){return(t=Cf(t/2))*t}function qf(){}function Uf(t,n){t&&Of.hasOwnProperty(t.type)&&Of[t.type](t,n)}var If={Feature:function(t,n){Uf(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r=0?1:-1,i=r*e,o=Tf(n=(n*=mf)/2+vf),a=Cf(n),u=Vf*a,c=Gf*o+u*Tf(i),f=u*r*Cf(i);as.add(Mf(f,c)),Xf=t,Gf=o,Vf=a}function ds(t){return[Mf(t[1],t[0]),Rf(t[2])]}function ps(t){var n=t[0],e=t[1],r=Tf(e);return[r*Tf(n),r*Cf(n),Cf(e)]}function gs(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]}function ys(t,n){return[t[1]*n[2]-t[2]*n[1],t[2]*n[0]-t[0]*n[2],t[0]*n[1]-t[1]*n[0]]}function vs(t,n){t[0]+=n[0],t[1]+=n[1],t[2]+=n[2]}function _s(t,n){return[t[0]*n,t[1]*n,t[2]*n]}function bs(t){var n=zf(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]);t[0]/=n,t[1]/=n,t[2]/=n}var ms,xs,ws,Ms,Ts,As,Ss,Es,Ns,ks,Cs,Ps,zs,$s,Ds,Rs,Fs={point:qs,lineStart:Is,lineEnd:Os,polygonStart:function(){Fs.point=Bs,Fs.lineStart=Ys,Fs.lineEnd=Ls,rs=new T,cs.polygonStart()},polygonEnd:function(){cs.polygonEnd(),Fs.point=qs,Fs.lineStart=Is,Fs.lineEnd=Os,as<0?(Wf=-(Kf=180),Zf=-(Qf=90)):rs>df?Qf=90:rs<-df&&(Zf=-90),os[0]=Wf,os[1]=Kf},sphere:function(){Wf=-(Kf=180),Zf=-(Qf=90)}};function qs(t,n){is.push(os=[Wf=t,Kf=t]),nQf&&(Qf=n)}function Us(t,n){var e=ps([t*mf,n*mf]);if(es){var r=ys(es,e),i=ys([r[1],-r[0],0],r);bs(i),i=ds(i);var o,a=t-Jf,u=a>0?1:-1,c=i[0]*bf*u,f=xf(a)>180;f^(u*JfQf&&(Qf=o):f^(u*Jf<(c=(c+360)%360-180)&&cQf&&(Qf=n)),f?tjs(Wf,Kf)&&(Kf=t):js(t,Kf)>js(Wf,Kf)&&(Wf=t):Kf>=Wf?(tKf&&(Kf=t)):t>Jf?js(Wf,t)>js(Wf,Kf)&&(Kf=t):js(t,Kf)>js(Wf,Kf)&&(Wf=t)}else is.push(os=[Wf=t,Kf=t]);nQf&&(Qf=n),es=e,Jf=t}function Is(){Fs.point=Us}function Os(){os[0]=Wf,os[1]=Kf,Fs.point=qs,es=null}function Bs(t,n){if(es){var e=t-Jf;rs.add(xf(e)>180?e+(e>0?360:-360):e)}else ts=t,ns=n;cs.point(t,n),Us(t,n)}function Ys(){cs.lineStart()}function Ls(){Bs(ts,ns),cs.lineEnd(),xf(rs)>df&&(Wf=-(Kf=180)),os[0]=Wf,os[1]=Kf,es=null}function js(t,n){return(n-=t)<0?n+360:n}function Hs(t,n){return t[0]-n[0]}function Xs(t,n){return t[0]<=t[1]?t[0]<=n&&n<=t[1]:ngf&&(t-=Math.round(t/_f)*_f),[t,n]}function ul(t,n,e){return(t%=_f)?n||e?ol(fl(t),sl(n,e)):fl(t):n||e?sl(n,e):al}function cl(t){return function(n,e){return xf(n+=t)>gf&&(n-=Math.round(n/_f)*_f),[n,e]}}function fl(t){var n=cl(t);return n.invert=cl(-t),n}function sl(t,n){var e=Tf(t),r=Cf(t),i=Tf(n),o=Cf(n);function a(t,n){var a=Tf(n),u=Tf(t)*a,c=Cf(t)*a,f=Cf(n),s=f*e+u*r;return[Mf(c*i-s*o,u*e-f*r),Rf(s*i+c*o)]}return a.invert=function(t,n){var a=Tf(n),u=Tf(t)*a,c=Cf(t)*a,f=Cf(n),s=f*i-c*o;return[Mf(c*i+f*o,u*e+s*r),Rf(s*e-u*r)]},a}function ll(t){function n(n){return(n=t(n[0]*mf,n[1]*mf))[0]*=bf,n[1]*=bf,n}return t=ul(t[0]*mf,t[1]*mf,t.length>2?t[2]*mf:0),n.invert=function(n){return(n=t.invert(n[0]*mf,n[1]*mf))[0]*=bf,n[1]*=bf,n},n}function hl(t,n,e,r,i,o){if(e){var a=Tf(n),u=Cf(n),c=r*e;null==i?(i=n+r*_f,o=n-c/2):(i=dl(a,i),o=dl(a,o),(r>0?io)&&(i+=r*_f));for(var f,s=i;r>0?s>o:s1&&n.push(n.pop().concat(n.shift()))},result:function(){var e=n;return n=[],t=null,e}}}function gl(t,n){return xf(t[0]-n[0])=0;--o)i.point((s=f[o])[0],s[1]);else r(h.x,h.p.x,-1,i);h=h.p}f=(h=h.o).z,d=!d}while(!h.v);i.lineEnd()}}}function _l(t){if(n=t.length){for(var n,e,r=0,i=t[0];++r=0?1:-1,E=S*A,N=E>gf,k=y*w;if(c.add(Mf(k*S*Cf(E),v*M+k*Tf(E))),a+=N?A+S*_f:A,N^p>=e^m>=e){var C=ys(ps(d),ps(b));bs(C);var P=ys(o,C);bs(P);var z=(N^A>=0?-1:1)*Rf(P[2]);(r>z||r===z&&(C[0]||C[1]))&&(u+=N^A>=0?1:-1)}}return(a<-df||a0){for(l||(i.polygonStart(),l=!0),i.lineStart(),t=0;t1&&2&c&&h.push(h.pop().concat(h.shift())),a.push(h.filter(wl))}return h}}function wl(t){return t.length>1}function Ml(t,n){return((t=t.x)[0]<0?t[1]-yf-df:yf-t[1])-((n=n.x)[0]<0?n[1]-yf-df:yf-n[1])}al.invert=al;var Tl=xl((function(){return!0}),(function(t){var n,e=NaN,r=NaN,i=NaN;return{lineStart:function(){t.lineStart(),n=1},point:function(o,a){var u=o>0?gf:-gf,c=xf(o-e);xf(c-gf)0?yf:-yf),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(u,r),t.point(o,r),n=0):i!==u&&c>=gf&&(xf(e-i)df?wf((Cf(n)*(o=Tf(r))*Cf(e)-Cf(r)*(i=Tf(n))*Cf(t))/(i*o*a)):(n+r)/2}(e,r,o,a),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(u,r),n=0),t.point(e=o,r=a),i=u},lineEnd:function(){t.lineEnd(),e=r=NaN},clean:function(){return 2-n}}}),(function(t,n,e,r){var i;if(null==t)i=e*yf,r.point(-gf,i),r.point(0,i),r.point(gf,i),r.point(gf,0),r.point(gf,-i),r.point(0,-i),r.point(-gf,-i),r.point(-gf,0),r.point(-gf,i);else if(xf(t[0]-n[0])>df){var o=t[0]0,i=xf(n)>df;function o(t,e){return Tf(t)*Tf(e)>n}function a(t,e,r){var i=[1,0,0],o=ys(ps(t),ps(e)),a=gs(o,o),u=o[0],c=a-u*u;if(!c)return!r&&t;var f=n*a/c,s=-n*u/c,l=ys(i,o),h=_s(i,f);vs(h,_s(o,s));var d=l,p=gs(h,d),g=gs(d,d),y=p*p-g*(gs(h,h)-1);if(!(y<0)){var v=zf(y),_=_s(d,(-p-v)/g);if(vs(_,h),_=ds(_),!r)return _;var b,m=t[0],x=e[0],w=t[1],M=e[1];x0^_[1]<(xf(_[0]-m)gf^(m<=_[0]&&_[0]<=x)){var S=_s(d,(-p+v)/g);return vs(S,h),[_,ds(S)]}}}function u(n,e){var i=r?t:gf-t,o=0;return n<-i?o|=1:n>i&&(o|=2),e<-i?o|=4:e>i&&(o|=8),o}return xl(o,(function(t){var n,e,c,f,s;return{lineStart:function(){f=c=!1,s=1},point:function(l,h){var d,p=[l,h],g=o(l,h),y=r?g?0:u(l,h):g?u(l+(l<0?gf:-gf),h):0;if(!n&&(f=c=g)&&t.lineStart(),g!==c&&(!(d=a(n,p))||gl(n,d)||gl(p,d))&&(p[2]=1),g!==c)s=0,g?(t.lineStart(),d=a(p,n),t.point(d[0],d[1])):(d=a(n,p),t.point(d[0],d[1],2),t.lineEnd()),n=d;else if(i&&n&&r^g){var v;y&e||!(v=a(p,n,!0))||(s=0,r?(t.lineStart(),t.point(v[0][0],v[0][1]),t.point(v[1][0],v[1][1]),t.lineEnd()):(t.point(v[1][0],v[1][1]),t.lineEnd(),t.lineStart(),t.point(v[0][0],v[0][1],3)))}!g||n&&gl(n,p)||t.point(p[0],p[1]),n=p,c=g,e=y},lineEnd:function(){c&&t.lineEnd(),n=null},clean:function(){return s|(f&&c)<<1}}}),(function(n,r,i,o){hl(o,t,e,i,n,r)}),r?[0,-t]:[-gf,t-gf])}var Sl,El,Nl,kl,Cl=1e9,Pl=-Cl;function zl(t,n,e,r){function i(i,o){return t<=i&&i<=e&&n<=o&&o<=r}function o(i,o,u,f){var s=0,l=0;if(null==i||(s=a(i,u))!==(l=a(o,u))||c(i,o)<0^u>0)do{f.point(0===s||3===s?t:e,s>1?r:n)}while((s=(s+u+4)%4)!==l);else f.point(o[0],o[1])}function a(r,i){return xf(r[0]-t)0?0:3:xf(r[0]-e)0?2:1:xf(r[1]-n)0?1:0:i>0?3:2}function u(t,n){return c(t.x,n.x)}function c(t,n){var e=a(t,1),r=a(n,1);return e!==r?e-r:0===e?n[1]-t[1]:1===e?t[0]-n[0]:2===e?t[1]-n[1]:n[0]-t[0]}return function(a){var c,f,s,l,h,d,p,g,y,v,_,b=a,m=pl(),x={point:w,lineStart:function(){x.point=M,f&&f.push(s=[]);v=!0,y=!1,p=g=NaN},lineEnd:function(){c&&(M(l,h),d&&y&&m.rejoin(),c.push(m.result()));x.point=w,y&&b.lineEnd()},polygonStart:function(){b=m,c=[],f=[],_=!0},polygonEnd:function(){var n=function(){for(var n=0,e=0,i=f.length;er&&(h-o)*(r-a)>(d-a)*(t-o)&&++n:d<=r&&(h-o)*(r-a)<(d-a)*(t-o)&&--n;return n}(),e=_&&n,i=(c=ft(c)).length;(e||i)&&(a.polygonStart(),e&&(a.lineStart(),o(null,null,1,a),a.lineEnd()),i&&vl(c,u,n,o,a),a.polygonEnd());b=a,c=f=s=null}};function w(t,n){i(t,n)&&b.point(t,n)}function M(o,a){var u=i(o,a);if(f&&s.push([o,a]),v)l=o,h=a,d=u,v=!1,u&&(b.lineStart(),b.point(o,a));else if(u&&y)b.point(o,a);else{var c=[p=Math.max(Pl,Math.min(Cl,p)),g=Math.max(Pl,Math.min(Cl,g))],m=[o=Math.max(Pl,Math.min(Cl,o)),a=Math.max(Pl,Math.min(Cl,a))];!function(t,n,e,r,i,o){var a,u=t[0],c=t[1],f=0,s=1,l=n[0]-u,h=n[1]-c;if(a=e-u,l||!(a>0)){if(a/=l,l<0){if(a0){if(a>s)return;a>f&&(f=a)}if(a=i-u,l||!(a<0)){if(a/=l,l<0){if(a>s)return;a>f&&(f=a)}else if(l>0){if(a0)){if(a/=h,h<0){if(a0){if(a>s)return;a>f&&(f=a)}if(a=o-c,h||!(a<0)){if(a/=h,h<0){if(a>s)return;a>f&&(f=a)}else if(h>0){if(a0&&(t[0]=u+f*l,t[1]=c+f*h),s<1&&(n[0]=u+s*l,n[1]=c+s*h),!0}}}}}(c,m,t,n,e,r)?u&&(b.lineStart(),b.point(o,a),_=!1):(y||(b.lineStart(),b.point(c[0],c[1])),b.point(m[0],m[1]),u||b.lineEnd(),_=!1)}p=o,g=a,y=u}return x}}var $l={sphere:qf,point:qf,lineStart:function(){$l.point=Rl,$l.lineEnd=Dl},lineEnd:qf,polygonStart:qf,polygonEnd:qf};function Dl(){$l.point=$l.lineEnd=qf}function Rl(t,n){El=t*=mf,Nl=Cf(n*=mf),kl=Tf(n),$l.point=Fl}function Fl(t,n){t*=mf;var e=Cf(n*=mf),r=Tf(n),i=xf(t-El),o=Tf(i),a=r*Cf(i),u=kl*e-Nl*r*o,c=Nl*e+kl*r*o;Sl.add(Mf(zf(a*a+u*u),c)),El=t,Nl=e,kl=r}function ql(t){return Sl=new T,Lf(t,$l),+Sl}var Ul=[null,null],Il={type:"LineString",coordinates:Ul};function Ol(t,n){return Ul[0]=t,Ul[1]=n,ql(Il)}var Bl={Feature:function(t,n){return Ll(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r0&&(i=Ol(t[o],t[o-1]))>0&&e<=i&&r<=i&&(e+r-i)*(1-Math.pow((e-r)/i,2))df})).map(c)).concat(lt(Af(o/d)*d,i,d).filter((function(t){return xf(t%g)>df})).map(f))}return v.lines=function(){return _().map((function(t){return{type:"LineString",coordinates:t}}))},v.outline=function(){return{type:"Polygon",coordinates:[s(r).concat(l(a).slice(1),s(e).reverse().slice(1),l(u).reverse().slice(1))]}},v.extent=function(t){return arguments.length?v.extentMajor(t).extentMinor(t):v.extentMinor()},v.extentMajor=function(t){return arguments.length?(r=+t[0][0],e=+t[1][0],u=+t[0][1],a=+t[1][1],r>e&&(t=r,r=e,e=t),u>a&&(t=u,u=a,a=t),v.precision(y)):[[r,u],[e,a]]},v.extentMinor=function(e){return arguments.length?(n=+e[0][0],t=+e[1][0],o=+e[0][1],i=+e[1][1],n>t&&(e=n,n=t,t=e),o>i&&(e=o,o=i,i=e),v.precision(y)):[[n,o],[t,i]]},v.step=function(t){return arguments.length?v.stepMajor(t).stepMinor(t):v.stepMinor()},v.stepMajor=function(t){return arguments.length?(p=+t[0],g=+t[1],v):[p,g]},v.stepMinor=function(t){return arguments.length?(h=+t[0],d=+t[1],v):[h,d]},v.precision=function(h){return arguments.length?(y=+h,c=Wl(o,i,90),f=Zl(n,t,y),s=Wl(u,a,90),l=Zl(r,e,y),v):y},v.extentMajor([[-180,-90+df],[180,90-df]]).extentMinor([[-180,-80-df],[180,80+df]])}var Ql,Jl,th,nh,eh=t=>t,rh=new T,ih=new T,oh={point:qf,lineStart:qf,lineEnd:qf,polygonStart:function(){oh.lineStart=ah,oh.lineEnd=fh},polygonEnd:function(){oh.lineStart=oh.lineEnd=oh.point=qf,rh.add(xf(ih)),ih=new T},result:function(){var t=rh/2;return rh=new T,t}};function ah(){oh.point=uh}function uh(t,n){oh.point=ch,Ql=th=t,Jl=nh=n}function ch(t,n){ih.add(nh*t-th*n),th=t,nh=n}function fh(){ch(Ql,Jl)}var sh=oh,lh=1/0,hh=lh,dh=-lh,ph=dh,gh={point:function(t,n){tdh&&(dh=t);nph&&(ph=n)},lineStart:qf,lineEnd:qf,polygonStart:qf,polygonEnd:qf,result:function(){var t=[[lh,hh],[dh,ph]];return dh=ph=-(hh=lh=1/0),t}};var yh,vh,_h,bh,mh=gh,xh=0,wh=0,Mh=0,Th=0,Ah=0,Sh=0,Eh=0,Nh=0,kh=0,Ch={point:Ph,lineStart:zh,lineEnd:Rh,polygonStart:function(){Ch.lineStart=Fh,Ch.lineEnd=qh},polygonEnd:function(){Ch.point=Ph,Ch.lineStart=zh,Ch.lineEnd=Rh},result:function(){var t=kh?[Eh/kh,Nh/kh]:Sh?[Th/Sh,Ah/Sh]:Mh?[xh/Mh,wh/Mh]:[NaN,NaN];return xh=wh=Mh=Th=Ah=Sh=Eh=Nh=kh=0,t}};function Ph(t,n){xh+=t,wh+=n,++Mh}function zh(){Ch.point=$h}function $h(t,n){Ch.point=Dh,Ph(_h=t,bh=n)}function Dh(t,n){var e=t-_h,r=n-bh,i=zf(e*e+r*r);Th+=i*(_h+t)/2,Ah+=i*(bh+n)/2,Sh+=i,Ph(_h=t,bh=n)}function Rh(){Ch.point=Ph}function Fh(){Ch.point=Uh}function qh(){Ih(yh,vh)}function Uh(t,n){Ch.point=Ih,Ph(yh=_h=t,vh=bh=n)}function Ih(t,n){var e=t-_h,r=n-bh,i=zf(e*e+r*r);Th+=i*(_h+t)/2,Ah+=i*(bh+n)/2,Sh+=i,Eh+=(i=bh*t-_h*n)*(_h+t),Nh+=i*(bh+n),kh+=3*i,Ph(_h=t,bh=n)}var Oh=Ch;function Bh(t){this._context=t}Bh.prototype={_radius:4.5,pointRadius:function(t){return this._radius=t,this},polygonStart:function(){this._line=0},polygonEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){0===this._line&&this._context.closePath(),this._point=NaN},point:function(t,n){switch(this._point){case 0:this._context.moveTo(t,n),this._point=1;break;case 1:this._context.lineTo(t,n);break;default:this._context.moveTo(t+this._radius,n),this._context.arc(t,n,this._radius,0,_f)}},result:qf};var Yh,Lh,jh,Hh,Xh,Gh=new T,Vh={point:qf,lineStart:function(){Vh.point=Wh},lineEnd:function(){Yh&&Zh(Lh,jh),Vh.point=qf},polygonStart:function(){Yh=!0},polygonEnd:function(){Yh=null},result:function(){var t=+Gh;return Gh=new T,t}};function Wh(t,n){Vh.point=Zh,Lh=Hh=t,jh=Xh=n}function Zh(t,n){Hh-=t,Xh-=n,Gh.add(zf(Hh*Hh+Xh*Xh)),Hh=t,Xh=n}var Kh=Vh;let Qh,Jh,td,nd;class ed{constructor(t){this._append=null==t?rd:function(t){const n=Math.floor(t);if(!(n>=0))throw new RangeError(`invalid digits: ${t}`);if(n>15)return rd;if(n!==Qh){const t=10**n;Qh=n,Jh=function(n){let e=1;this._+=n[0];for(const r=n.length;e4*n&&g--){var m=a+h,x=u+d,w=c+p,M=zf(m*m+x*x+w*w),T=Rf(w/=M),A=xf(xf(w)-1)n||xf((v*k+_*C)/b-.5)>.3||a*h+u*d+c*p2?t[2]%360*mf:0,k()):[y*bf,v*bf,_*bf]},E.angle=function(t){return arguments.length?(b=t%360*mf,k()):b*bf},E.reflectX=function(t){return arguments.length?(m=t?-1:1,k()):m<0},E.reflectY=function(t){return arguments.length?(x=t?-1:1,k()):x<0},E.precision=function(t){return arguments.length?(a=dd(u,S=t*t),C()):zf(S)},E.fitExtent=function(t,n){return ud(E,t,n)},E.fitSize=function(t,n){return cd(E,t,n)},E.fitWidth=function(t,n){return fd(E,t,n)},E.fitHeight=function(t,n){return sd(E,t,n)},function(){return n=t.apply(this,arguments),E.invert=n.invert&&N,k()}}function _d(t){var n=0,e=gf/3,r=vd(t),i=r(n,e);return i.parallels=function(t){return arguments.length?r(n=t[0]*mf,e=t[1]*mf):[n*bf,e*bf]},i}function bd(t,n){var e=Cf(t),r=(e+Cf(n))/2;if(xf(r)0?n<-yf+df&&(n=-yf+df):n>yf-df&&(n=yf-df);var e=i/kf(Nd(n),r);return[e*Cf(r*t),i-e*Tf(r*t)]}return o.invert=function(t,n){var e=i-n,o=Pf(r)*zf(t*t+e*e),a=Mf(t,xf(e))*Pf(e);return e*r<0&&(a-=gf*Pf(t)*Pf(e)),[a/r,2*wf(kf(i/o,1/r))-yf]},o}function Cd(t,n){return[t,n]}function Pd(t,n){var e=Tf(t),r=t===n?Cf(t):(e-Tf(n))/(n-t),i=e/r+t;if(xf(r)=0;)n+=e[r].value;else n=1;t.value=n}function Gd(t,n){t instanceof Map?(t=[void 0,t],void 0===n&&(n=Wd)):void 0===n&&(n=Vd);for(var e,r,i,o,a,u=new Qd(t),c=[u];e=c.pop();)if((i=n(e.data))&&(a=(i=Array.from(i)).length))for(e.children=i,o=a-1;o>=0;--o)c.push(r=i[o]=new Qd(i[o])),r.parent=e,r.depth=e.depth+1;return u.eachBefore(Kd)}function Vd(t){return t.children}function Wd(t){return Array.isArray(t)?t[1]:null}function Zd(t){void 0!==t.data.value&&(t.value=t.data.value),t.data=t.data.data}function Kd(t){var n=0;do{t.height=n}while((t=t.parent)&&t.height<++n)}function Qd(t){this.data=t,this.depth=this.height=0,this.parent=null}function Jd(t){return null==t?null:tp(t)}function tp(t){if("function"!=typeof t)throw new Error;return t}function np(){return 0}function ep(t){return function(){return t}}qd.invert=function(t,n){for(var e,r=n,i=r*r,o=i*i*i,a=0;a<12&&(o=(i=(r-=e=(r*(zd+$d*i+o*(Dd+Rd*i))-n)/(zd+3*$d*i+o*(7*Dd+9*Rd*i)))*r)*i*i,!(xf(e)df&&--i>0);return[t/(.8707+(o=r*r)*(o*(o*o*o*(.003971-.001529*o)-.013791)-.131979)),r]},Od.invert=Md(Rf),Bd.invert=Md((function(t){return 2*wf(t)})),Yd.invert=function(t,n){return[-n,2*wf(Sf(t))-yf]},Qd.prototype=Gd.prototype={constructor:Qd,count:function(){return this.eachAfter(Xd)},each:function(t,n){let e=-1;for(const r of this)t.call(n,r,++e,this);return this},eachAfter:function(t,n){for(var e,r,i,o=this,a=[o],u=[],c=-1;o=a.pop();)if(u.push(o),e=o.children)for(r=0,i=e.length;r=0;--r)o.push(e[r]);return this},find:function(t,n){let e=-1;for(const r of this)if(t.call(n,r,++e,this))return r},sum:function(t){return this.eachAfter((function(n){for(var e=+t(n.data)||0,r=n.children,i=r&&r.length;--i>=0;)e+=r[i].value;n.value=e}))},sort:function(t){return this.eachBefore((function(n){n.children&&n.children.sort(t)}))},path:function(t){for(var n=this,e=function(t,n){if(t===n)return t;var e=t.ancestors(),r=n.ancestors(),i=null;t=e.pop(),n=r.pop();for(;t===n;)i=t,t=e.pop(),n=r.pop();return i}(n,t),r=[n];n!==e;)n=n.parent,r.push(n);for(var i=r.length;t!==e;)r.splice(i,0,t),t=t.parent;return r},ancestors:function(){for(var t=this,n=[t];t=t.parent;)n.push(t);return n},descendants:function(){return Array.from(this)},leaves:function(){var t=[];return this.eachBefore((function(n){n.children||t.push(n)})),t},links:function(){var t=this,n=[];return t.each((function(e){e!==t&&n.push({source:e.parent,target:e})})),n},copy:function(){return Gd(this).eachBefore(Zd)},[Symbol.iterator]:function*(){var t,n,e,r,i=this,o=[i];do{for(t=o.reverse(),o=[];i=t.pop();)if(yield i,n=i.children)for(e=0,r=n.length;e(t=(rp*t+ip)%op)/op}function up(t,n){for(var e,r,i=0,o=(t=function(t,n){let e,r,i=t.length;for(;i;)r=n()*i--|0,e=t[i],t[i]=t[r],t[r]=e;return t}(Array.from(t),n)).length,a=[];i0&&e*e>r*r+i*i}function lp(t,n){for(var e=0;e1e-6?(E+Math.sqrt(E*E-4*S*N))/(2*S):N/E);return{x:r+w+M*k,y:i+T+A*k,r:k}}function gp(t,n,e){var r,i,o,a,u=t.x-n.x,c=t.y-n.y,f=u*u+c*c;f?(i=n.r+e.r,i*=i,a=t.r+e.r,i>(a*=a)?(r=(f+a-i)/(2*f),o=Math.sqrt(Math.max(0,a/f-r*r)),e.x=t.x-r*u-o*c,e.y=t.y-r*c+o*u):(r=(f+i-a)/(2*f),o=Math.sqrt(Math.max(0,i/f-r*r)),e.x=n.x+r*u-o*c,e.y=n.y+r*c+o*u)):(e.x=n.x+e.r,e.y=n.y)}function yp(t,n){var e=t.r+n.r-1e-6,r=n.x-t.x,i=n.y-t.y;return e>0&&e*e>r*r+i*i}function vp(t){var n=t._,e=t.next._,r=n.r+e.r,i=(n.x*e.r+e.x*n.r)/r,o=(n.y*e.r+e.y*n.r)/r;return i*i+o*o}function _p(t){this._=t,this.next=null,this.previous=null}function bp(t,n){if(!(o=(t=function(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}(t)).length))return 0;var e,r,i,o,a,u,c,f,s,l,h;if((e=t[0]).x=0,e.y=0,!(o>1))return e.r;if(r=t[1],e.x=-r.r,r.x=e.r,r.y=0,!(o>2))return e.r+r.r;gp(r,e,i=t[2]),e=new _p(e),r=new _p(r),i=new _p(i),e.next=i.previous=r,r.next=e.previous=i,i.next=r.previous=e;t:for(c=3;c1&&!zp(t,n););return t.slice(0,n)}function zp(t,n){if("/"===t[n]){let e=0;for(;n>0&&"\\"===t[--n];)++e;if(!(1&e))return!0}return!1}function $p(t,n){return t.parent===n.parent?1:2}function Dp(t){var n=t.children;return n?n[0]:t.t}function Rp(t){var n=t.children;return n?n[n.length-1]:t.t}function Fp(t,n,e){var r=e/(n.i-t.i);n.c-=r,n.s+=e,t.c+=r,n.z+=e,n.m+=e}function qp(t,n,e){return t.a.parent===n.parent?t.a:e}function Up(t,n){this._=t,this.parent=null,this.children=null,this.A=null,this.a=this,this.z=0,this.m=0,this.c=0,this.s=0,this.t=null,this.i=n}function Ip(t,n,e,r,i){for(var o,a=t.children,u=-1,c=a.length,f=t.value&&(i-e)/t.value;++uh&&(h=u),y=s*s*g,(d=Math.max(h/y,y/l))>p){s-=u;break}p=d}v.push(a={value:s,dice:c1?n:1)},e}(Op);var Lp=function t(n){function e(t,e,r,i,o){if((a=t._squarify)&&a.ratio===n)for(var a,u,c,f,s,l=-1,h=a.length,d=t.value;++l1?n:1)},e}(Op);function jp(t,n,e){return(n[0]-t[0])*(e[1]-t[1])-(n[1]-t[1])*(e[0]-t[0])}function Hp(t,n){return t[0]-n[0]||t[1]-n[1]}function Xp(t){const n=t.length,e=[0,1];let r,i=2;for(r=2;r1&&jp(t[e[i-2]],t[e[i-1]],t[r])<=0;)--i;e[i++]=r}return e.slice(0,i)}var Gp=Math.random,Vp=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,1===arguments.length?(e=t,t=0):e-=t,function(){return n()*e+t}}return e.source=t,e}(Gp),Wp=function t(n){function e(t,e){return arguments.length<2&&(e=t,t=0),t=Math.floor(t),e=Math.floor(e)-t,function(){return Math.floor(n()*e+t)}}return e.source=t,e}(Gp),Zp=function t(n){function e(t,e){var r,i;return t=null==t?0:+t,e=null==e?1:+e,function(){var o;if(null!=r)o=r,r=null;else do{r=2*n()-1,o=2*n()-1,i=r*r+o*o}while(!i||i>1);return t+e*o*Math.sqrt(-2*Math.log(i)/i)}}return e.source=t,e}(Gp),Kp=function t(n){var e=Zp.source(n);function r(){var t=e.apply(this,arguments);return function(){return Math.exp(t())}}return r.source=t,r}(Gp),Qp=function t(n){function e(t){return(t=+t)<=0?()=>0:function(){for(var e=0,r=t;r>1;--r)e+=n();return e+r*n()}}return e.source=t,e}(Gp),Jp=function t(n){var e=Qp.source(n);function r(t){if(0==(t=+t))return n;var r=e(t);return function(){return r()/t}}return r.source=t,r}(Gp),tg=function t(n){function e(t){return function(){return-Math.log1p(-n())/t}}return e.source=t,e}(Gp),ng=function t(n){function e(t){if((t=+t)<0)throw new RangeError("invalid alpha");return t=1/-t,function(){return Math.pow(1-n(),t)}}return e.source=t,e}(Gp),eg=function t(n){function e(t){if((t=+t)<0||t>1)throw new RangeError("invalid p");return function(){return Math.floor(n()+t)}}return e.source=t,e}(Gp),rg=function t(n){function e(t){if((t=+t)<0||t>1)throw new RangeError("invalid p");return 0===t?()=>1/0:1===t?()=>1:(t=Math.log1p(-t),function(){return 1+Math.floor(Math.log1p(-n())/t)})}return e.source=t,e}(Gp),ig=function t(n){var e=Zp.source(n)();function r(t,r){if((t=+t)<0)throw new RangeError("invalid k");if(0===t)return()=>0;if(r=null==r?1:+r,1===t)return()=>-Math.log1p(-n())*r;var i=(t<1?t+1:t)-1/3,o=1/(3*Math.sqrt(i)),a=t<1?()=>Math.pow(n(),1/t):()=>1;return function(){do{do{var t=e(),u=1+o*t}while(u<=0);u*=u*u;var c=1-n()}while(c>=1-.0331*t*t*t*t&&Math.log(c)>=.5*t*t+i*(1-u+Math.log(u)));return i*u*a()*r}}return r.source=t,r}(Gp),og=function t(n){var e=ig.source(n);function r(t,n){var r=e(t),i=e(n);return function(){var t=r();return 0===t?0:t/(t+i())}}return r.source=t,r}(Gp),ag=function t(n){var e=rg.source(n),r=og.source(n);function i(t,n){return t=+t,(n=+n)>=1?()=>t:n<=0?()=>0:function(){for(var i=0,o=t,a=n;o*a>16&&o*(1-a)>16;){var u=Math.floor((o+1)*a),c=r(u,o-u+1)();c<=a?(i+=u,o-=u,a=(a-c)/(1-c)):(o=u-1,a/=c)}for(var f=a<.5,s=e(f?a:1-a),l=s(),h=0;l<=o;++h)l+=s();return i+(f?h:o-h)}}return i.source=t,i}(Gp),ug=function t(n){function e(t,e,r){var i;return 0==(t=+t)?i=t=>-Math.log(t):(t=1/t,i=n=>Math.pow(n,t)),e=null==e?0:+e,r=null==r?1:+r,function(){return e+r*i(-Math.log1p(-n()))}}return e.source=t,e}(Gp),cg=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,function(){return t+e*Math.tan(Math.PI*n())}}return e.source=t,e}(Gp),fg=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,function(){var r=n();return t+e*Math.log(r/(1-r))}}return e.source=t,e}(Gp),sg=function t(n){var e=ig.source(n),r=ag.source(n);function i(t){return function(){for(var i=0,o=t;o>16;){var a=Math.floor(.875*o),u=e(a)();if(u>o)return i+r(a-1,o/u)();i+=a,o-=u}for(var c=-Math.log1p(-n()),f=0;c<=o;++f)c-=Math.log1p(-n());return i+f}}return i.source=t,i}(Gp);const lg=1/4294967296;function hg(t,n){switch(arguments.length){case 0:break;case 1:this.range(t);break;default:this.range(n).domain(t)}return this}function dg(t,n){switch(arguments.length){case 0:break;case 1:"function"==typeof t?this.interpolator(t):this.range(t);break;default:this.domain(t),"function"==typeof n?this.interpolator(n):this.range(n)}return this}const pg=Symbol("implicit");function gg(){var t=new InternMap,n=[],e=[],r=pg;function i(i){let o=t.get(i);if(void 0===o){if(r!==pg)return r;t.set(i,o=n.push(i)-1)}return e[o%e.length]}return i.domain=function(e){if(!arguments.length)return n.slice();n=[],t=new InternMap;for(const r of e)t.has(r)||t.set(r,n.push(r)-1);return i},i.range=function(t){return arguments.length?(e=Array.from(t),i):e.slice()},i.unknown=function(t){return arguments.length?(r=t,i):r},i.copy=function(){return gg(n,e).unknown(r)},hg.apply(i,arguments),i}function yg(){var t,n,e=gg().unknown(void 0),r=e.domain,i=e.range,o=0,a=1,u=!1,c=0,f=0,s=.5;function l(){var e=r().length,l=an&&(e=t,t=n,n=e),function(e){return Math.max(t,Math.min(n,e))}}(a[0],a[t-1])),r=t>2?Mg:wg,i=o=null,l}function l(n){return null==n||isNaN(n=+n)?e:(i||(i=r(a.map(t),u,c)))(t(f(n)))}return l.invert=function(e){return f(n((o||(o=r(u,a.map(t),Yr)))(e)))},l.domain=function(t){return arguments.length?(a=Array.from(t,_g),s()):a.slice()},l.range=function(t){return arguments.length?(u=Array.from(t),s()):u.slice()},l.rangeRound=function(t){return u=Array.from(t),c=Vr,s()},l.clamp=function(t){return arguments.length?(f=!!t||mg,s()):f!==mg},l.interpolate=function(t){return arguments.length?(c=t,s()):c},l.unknown=function(t){return arguments.length?(e=t,l):e},function(e,r){return t=e,n=r,s()}}function Sg(){return Ag()(mg,mg)}function Eg(n,e,r,i){var o,a=W(n,e,r);switch((i=Jc(null==i?",f":i)).type){case"s":var u=Math.max(Math.abs(n),Math.abs(e));return null!=i.precision||isNaN(o=lf(a,u))||(i.precision=o),t.formatPrefix(i,u);case"":case"e":case"g":case"p":case"r":null!=i.precision||isNaN(o=hf(a,Math.max(Math.abs(n),Math.abs(e))))||(i.precision=o-("e"===i.type));break;case"f":case"%":null!=i.precision||isNaN(o=sf(a))||(i.precision=o-2*("%"===i.type))}return t.format(i)}function Ng(t){var n=t.domain;return t.ticks=function(t){var e=n();return G(e[0],e[e.length-1],null==t?10:t)},t.tickFormat=function(t,e){var r=n();return Eg(r[0],r[r.length-1],null==t?10:t,e)},t.nice=function(e){null==e&&(e=10);var r,i,o=n(),a=0,u=o.length-1,c=o[a],f=o[u],s=10;for(f0;){if((i=V(c,f,e))===r)return o[a]=c,o[u]=f,n(o);if(i>0)c=Math.floor(c/i)*i,f=Math.ceil(f/i)*i;else{if(!(i<0))break;c=Math.ceil(c*i)/i,f=Math.floor(f*i)/i}r=i}return t},t}function kg(t,n){var e,r=0,i=(t=t.slice()).length-1,o=t[r],a=t[i];return a-t(-n,e)}function Fg(n){const e=n(Cg,Pg),r=e.domain;let i,o,a=10;function u(){return i=function(t){return t===Math.E?Math.log:10===t&&Math.log10||2===t&&Math.log2||(t=Math.log(t),n=>Math.log(n)/t)}(a),o=function(t){return 10===t?Dg:t===Math.E?Math.exp:n=>Math.pow(t,n)}(a),r()[0]<0?(i=Rg(i),o=Rg(o),n(zg,$g)):n(Cg,Pg),e}return e.base=function(t){return arguments.length?(a=+t,u()):a},e.domain=function(t){return arguments.length?(r(t),u()):r()},e.ticks=t=>{const n=r();let e=n[0],u=n[n.length-1];const c=u0){for(;l<=h;++l)for(f=1;fu)break;p.push(s)}}else for(;l<=h;++l)for(f=a-1;f>=1;--f)if(s=l>0?f/o(-l):f*o(l),!(su)break;p.push(s)}2*p.length{if(null==n&&(n=10),null==r&&(r=10===a?"s":","),"function"!=typeof r&&(a%1||null!=(r=Jc(r)).precision||(r.trim=!0),r=t.format(r)),n===1/0)return r;const u=Math.max(1,a*n/e.ticks().length);return t=>{let n=t/o(Math.round(i(t)));return n*ar(kg(r(),{floor:t=>o(Math.floor(i(t))),ceil:t=>o(Math.ceil(i(t)))})),e}function qg(t){return function(n){return Math.sign(n)*Math.log1p(Math.abs(n/t))}}function Ug(t){return function(n){return Math.sign(n)*Math.expm1(Math.abs(n))*t}}function Ig(t){var n=1,e=t(qg(n),Ug(n));return e.constant=function(e){return arguments.length?t(qg(n=+e),Ug(n)):n},Ng(e)}function Og(t){return function(n){return n<0?-Math.pow(-n,t):Math.pow(n,t)}}function Bg(t){return t<0?-Math.sqrt(-t):Math.sqrt(t)}function Yg(t){return t<0?-t*t:t*t}function Lg(t){var n=t(mg,mg),e=1;return n.exponent=function(n){return arguments.length?1===(e=+n)?t(mg,mg):.5===e?t(Bg,Yg):t(Og(e),Og(1/e)):e},Ng(n)}function jg(){var t=Lg(Ag());return t.copy=function(){return Tg(t,jg()).exponent(t.exponent())},hg.apply(t,arguments),t}function Hg(t){return Math.sign(t)*t*t}const Xg=new Date,Gg=new Date;function Vg(t,n,e,r){function i(n){return t(n=0===arguments.length?new Date:new Date(+n)),n}return i.floor=n=>(t(n=new Date(+n)),n),i.ceil=e=>(t(e=new Date(e-1)),n(e,1),t(e),e),i.round=t=>{const n=i(t),e=i.ceil(t);return t-n(n(t=new Date(+t),null==e?1:Math.floor(e)),t),i.range=(e,r,o)=>{const a=[];if(e=i.ceil(e),o=null==o?1:Math.floor(o),!(e0))return a;let u;do{a.push(u=new Date(+e)),n(e,o),t(e)}while(uVg((n=>{if(n>=n)for(;t(n),!e(n);)n.setTime(n-1)}),((t,r)=>{if(t>=t)if(r<0)for(;++r<=0;)for(;n(t,-1),!e(t););else for(;--r>=0;)for(;n(t,1),!e(t););})),e&&(i.count=(n,r)=>(Xg.setTime(+n),Gg.setTime(+r),t(Xg),t(Gg),Math.floor(e(Xg,Gg))),i.every=t=>(t=Math.floor(t),isFinite(t)&&t>0?t>1?i.filter(r?n=>r(n)%t==0:n=>i.count(0,n)%t==0):i:null)),i}const Wg=Vg((()=>{}),((t,n)=>{t.setTime(+t+n)}),((t,n)=>n-t));Wg.every=t=>(t=Math.floor(t),isFinite(t)&&t>0?t>1?Vg((n=>{n.setTime(Math.floor(n/t)*t)}),((n,e)=>{n.setTime(+n+e*t)}),((n,e)=>(e-n)/t)):Wg:null);const Zg=Wg.range,Kg=1e3,Qg=6e4,Jg=36e5,ty=864e5,ny=6048e5,ey=2592e6,ry=31536e6,iy=Vg((t=>{t.setTime(t-t.getMilliseconds())}),((t,n)=>{t.setTime(+t+n*Kg)}),((t,n)=>(n-t)/Kg),(t=>t.getUTCSeconds())),oy=iy.range,ay=Vg((t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*Kg)}),((t,n)=>{t.setTime(+t+n*Qg)}),((t,n)=>(n-t)/Qg),(t=>t.getMinutes())),uy=ay.range,cy=Vg((t=>{t.setUTCSeconds(0,0)}),((t,n)=>{t.setTime(+t+n*Qg)}),((t,n)=>(n-t)/Qg),(t=>t.getUTCMinutes())),fy=cy.range,sy=Vg((t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*Kg-t.getMinutes()*Qg)}),((t,n)=>{t.setTime(+t+n*Jg)}),((t,n)=>(n-t)/Jg),(t=>t.getHours())),ly=sy.range,hy=Vg((t=>{t.setUTCMinutes(0,0,0)}),((t,n)=>{t.setTime(+t+n*Jg)}),((t,n)=>(n-t)/Jg),(t=>t.getUTCHours())),dy=hy.range,py=Vg((t=>t.setHours(0,0,0,0)),((t,n)=>t.setDate(t.getDate()+n)),((t,n)=>(n-t-(n.getTimezoneOffset()-t.getTimezoneOffset())*Qg)/ty),(t=>t.getDate()-1)),gy=py.range,yy=Vg((t=>{t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCDate(t.getUTCDate()+n)}),((t,n)=>(n-t)/ty),(t=>t.getUTCDate()-1)),vy=yy.range,_y=Vg((t=>{t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCDate(t.getUTCDate()+n)}),((t,n)=>(n-t)/ty),(t=>Math.floor(t/ty))),by=_y.range;function my(t){return Vg((n=>{n.setDate(n.getDate()-(n.getDay()+7-t)%7),n.setHours(0,0,0,0)}),((t,n)=>{t.setDate(t.getDate()+7*n)}),((t,n)=>(n-t-(n.getTimezoneOffset()-t.getTimezoneOffset())*Qg)/ny))}const xy=my(0),wy=my(1),My=my(2),Ty=my(3),Ay=my(4),Sy=my(5),Ey=my(6),Ny=xy.range,ky=wy.range,Cy=My.range,Py=Ty.range,zy=Ay.range,$y=Sy.range,Dy=Ey.range;function Ry(t){return Vg((n=>{n.setUTCDate(n.getUTCDate()-(n.getUTCDay()+7-t)%7),n.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCDate(t.getUTCDate()+7*n)}),((t,n)=>(n-t)/ny))}const Fy=Ry(0),qy=Ry(1),Uy=Ry(2),Iy=Ry(3),Oy=Ry(4),By=Ry(5),Yy=Ry(6),Ly=Fy.range,jy=qy.range,Hy=Uy.range,Xy=Iy.range,Gy=Oy.range,Vy=By.range,Wy=Yy.range,Zy=Vg((t=>{t.setDate(1),t.setHours(0,0,0,0)}),((t,n)=>{t.setMonth(t.getMonth()+n)}),((t,n)=>n.getMonth()-t.getMonth()+12*(n.getFullYear()-t.getFullYear())),(t=>t.getMonth())),Ky=Zy.range,Qy=Vg((t=>{t.setUTCDate(1),t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCMonth(t.getUTCMonth()+n)}),((t,n)=>n.getUTCMonth()-t.getUTCMonth()+12*(n.getUTCFullYear()-t.getUTCFullYear())),(t=>t.getUTCMonth())),Jy=Qy.range,tv=Vg((t=>{t.setMonth(0,1),t.setHours(0,0,0,0)}),((t,n)=>{t.setFullYear(t.getFullYear()+n)}),((t,n)=>n.getFullYear()-t.getFullYear()),(t=>t.getFullYear()));tv.every=t=>isFinite(t=Math.floor(t))&&t>0?Vg((n=>{n.setFullYear(Math.floor(n.getFullYear()/t)*t),n.setMonth(0,1),n.setHours(0,0,0,0)}),((n,e)=>{n.setFullYear(n.getFullYear()+e*t)})):null;const nv=tv.range,ev=Vg((t=>{t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCFullYear(t.getUTCFullYear()+n)}),((t,n)=>n.getUTCFullYear()-t.getUTCFullYear()),(t=>t.getUTCFullYear()));ev.every=t=>isFinite(t=Math.floor(t))&&t>0?Vg((n=>{n.setUTCFullYear(Math.floor(n.getUTCFullYear()/t)*t),n.setUTCMonth(0,1),n.setUTCHours(0,0,0,0)}),((n,e)=>{n.setUTCFullYear(n.getUTCFullYear()+e*t)})):null;const rv=ev.range;function iv(t,n,e,i,o,a){const u=[[iy,1,Kg],[iy,5,5e3],[iy,15,15e3],[iy,30,3e4],[a,1,Qg],[a,5,3e5],[a,15,9e5],[a,30,18e5],[o,1,Jg],[o,3,108e5],[o,6,216e5],[o,12,432e5],[i,1,ty],[i,2,1728e5],[e,1,ny],[n,1,ey],[n,3,7776e6],[t,1,ry]];function c(n,e,i){const o=Math.abs(e-n)/i,a=r((([,,t])=>t)).right(u,o);if(a===u.length)return t.every(W(n/ry,e/ry,i));if(0===a)return Wg.every(Math.max(W(n,e,i),1));const[c,f]=u[o/u[a-1][2]=12)]},q:function(t){return 1+~~(t.getMonth()/3)},Q:k_,s:C_,S:Zv,u:Kv,U:Qv,V:t_,w:n_,W:e_,x:null,X:null,y:r_,Y:o_,Z:u_,"%":N_},m={a:function(t){return a[t.getUTCDay()]},A:function(t){return o[t.getUTCDay()]},b:function(t){return c[t.getUTCMonth()]},B:function(t){return u[t.getUTCMonth()]},c:null,d:c_,e:c_,f:d_,g:T_,G:S_,H:f_,I:s_,j:l_,L:h_,m:p_,M:g_,p:function(t){return i[+(t.getUTCHours()>=12)]},q:function(t){return 1+~~(t.getUTCMonth()/3)},Q:k_,s:C_,S:y_,u:v_,U:__,V:m_,w:x_,W:w_,x:null,X:null,y:M_,Y:A_,Z:E_,"%":N_},x={a:function(t,n,e){var r=d.exec(n.slice(e));return r?(t.w=p.get(r[0].toLowerCase()),e+r[0].length):-1},A:function(t,n,e){var r=l.exec(n.slice(e));return r?(t.w=h.get(r[0].toLowerCase()),e+r[0].length):-1},b:function(t,n,e){var r=v.exec(n.slice(e));return r?(t.m=_.get(r[0].toLowerCase()),e+r[0].length):-1},B:function(t,n,e){var r=g.exec(n.slice(e));return r?(t.m=y.get(r[0].toLowerCase()),e+r[0].length):-1},c:function(t,e,r){return T(t,n,e,r)},d:zv,e:zv,f:Uv,g:Nv,G:Ev,H:Dv,I:Dv,j:$v,L:qv,m:Pv,M:Rv,p:function(t,n,e){var r=f.exec(n.slice(e));return r?(t.p=s.get(r[0].toLowerCase()),e+r[0].length):-1},q:Cv,Q:Ov,s:Bv,S:Fv,u:Mv,U:Tv,V:Av,w:wv,W:Sv,x:function(t,n,r){return T(t,e,n,r)},X:function(t,n,e){return T(t,r,n,e)},y:Nv,Y:Ev,Z:kv,"%":Iv};function w(t,n){return function(e){var r,i,o,a=[],u=-1,c=0,f=t.length;for(e instanceof Date||(e=new Date(+e));++u53)return null;"w"in o||(o.w=1),"Z"in o?(i=(r=sv(lv(o.y,0,1))).getUTCDay(),r=i>4||0===i?qy.ceil(r):qy(r),r=yy.offset(r,7*(o.V-1)),o.y=r.getUTCFullYear(),o.m=r.getUTCMonth(),o.d=r.getUTCDate()+(o.w+6)%7):(i=(r=fv(lv(o.y,0,1))).getDay(),r=i>4||0===i?wy.ceil(r):wy(r),r=py.offset(r,7*(o.V-1)),o.y=r.getFullYear(),o.m=r.getMonth(),o.d=r.getDate()+(o.w+6)%7)}else("W"in o||"U"in o)&&("w"in o||(o.w="u"in o?o.u%7:"W"in o?1:0),i="Z"in o?sv(lv(o.y,0,1)).getUTCDay():fv(lv(o.y,0,1)).getDay(),o.m=0,o.d="W"in o?(o.w+6)%7+7*o.W-(i+5)%7:o.w+7*o.U-(i+6)%7);return"Z"in o?(o.H+=o.Z/100|0,o.M+=o.Z%100,sv(o)):fv(o)}}function T(t,n,e,r){for(var i,o,a=0,u=n.length,c=e.length;a=c)return-1;if(37===(i=n.charCodeAt(a++))){if(i=n.charAt(a++),!(o=x[i in pv?n.charAt(a++):i])||(r=o(t,e,r))<0)return-1}else if(i!=e.charCodeAt(r++))return-1}return r}return b.x=w(e,b),b.X=w(r,b),b.c=w(n,b),m.x=w(e,m),m.X=w(r,m),m.c=w(n,m),{format:function(t){var n=w(t+="",b);return n.toString=function(){return t},n},parse:function(t){var n=M(t+="",!1);return n.toString=function(){return t},n},utcFormat:function(t){var n=w(t+="",m);return n.toString=function(){return t},n},utcParse:function(t){var n=M(t+="",!0);return n.toString=function(){return t},n}}}var dv,pv={"-":"",_:" ",0:"0"},gv=/^\s*\d+/,yv=/^%/,vv=/[\\^$*+?|[\]().{}]/g;function _v(t,n,e){var r=t<0?"-":"",i=(r?-t:t)+"",o=i.length;return r+(o[t.toLowerCase(),n])))}function wv(t,n,e){var r=gv.exec(n.slice(e,e+1));return r?(t.w=+r[0],e+r[0].length):-1}function Mv(t,n,e){var r=gv.exec(n.slice(e,e+1));return r?(t.u=+r[0],e+r[0].length):-1}function Tv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.U=+r[0],e+r[0].length):-1}function Av(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.V=+r[0],e+r[0].length):-1}function Sv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.W=+r[0],e+r[0].length):-1}function Ev(t,n,e){var r=gv.exec(n.slice(e,e+4));return r?(t.y=+r[0],e+r[0].length):-1}function Nv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.y=+r[0]+(+r[0]>68?1900:2e3),e+r[0].length):-1}function kv(t,n,e){var r=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(n.slice(e,e+6));return r?(t.Z=r[1]?0:-(r[2]+(r[3]||"00")),e+r[0].length):-1}function Cv(t,n,e){var r=gv.exec(n.slice(e,e+1));return r?(t.q=3*r[0]-3,e+r[0].length):-1}function Pv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.m=r[0]-1,e+r[0].length):-1}function zv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.d=+r[0],e+r[0].length):-1}function $v(t,n,e){var r=gv.exec(n.slice(e,e+3));return r?(t.m=0,t.d=+r[0],e+r[0].length):-1}function Dv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.H=+r[0],e+r[0].length):-1}function Rv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.M=+r[0],e+r[0].length):-1}function Fv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.S=+r[0],e+r[0].length):-1}function qv(t,n,e){var r=gv.exec(n.slice(e,e+3));return r?(t.L=+r[0],e+r[0].length):-1}function Uv(t,n,e){var r=gv.exec(n.slice(e,e+6));return r?(t.L=Math.floor(r[0]/1e3),e+r[0].length):-1}function Iv(t,n,e){var r=yv.exec(n.slice(e,e+1));return r?e+r[0].length:-1}function Ov(t,n,e){var r=gv.exec(n.slice(e));return r?(t.Q=+r[0],e+r[0].length):-1}function Bv(t,n,e){var r=gv.exec(n.slice(e));return r?(t.s=+r[0],e+r[0].length):-1}function Yv(t,n){return _v(t.getDate(),n,2)}function Lv(t,n){return _v(t.getHours(),n,2)}function jv(t,n){return _v(t.getHours()%12||12,n,2)}function Hv(t,n){return _v(1+py.count(tv(t),t),n,3)}function Xv(t,n){return _v(t.getMilliseconds(),n,3)}function Gv(t,n){return Xv(t,n)+"000"}function Vv(t,n){return _v(t.getMonth()+1,n,2)}function Wv(t,n){return _v(t.getMinutes(),n,2)}function Zv(t,n){return _v(t.getSeconds(),n,2)}function Kv(t){var n=t.getDay();return 0===n?7:n}function Qv(t,n){return _v(xy.count(tv(t)-1,t),n,2)}function Jv(t){var n=t.getDay();return n>=4||0===n?Ay(t):Ay.ceil(t)}function t_(t,n){return t=Jv(t),_v(Ay.count(tv(t),t)+(4===tv(t).getDay()),n,2)}function n_(t){return t.getDay()}function e_(t,n){return _v(wy.count(tv(t)-1,t),n,2)}function r_(t,n){return _v(t.getFullYear()%100,n,2)}function i_(t,n){return _v((t=Jv(t)).getFullYear()%100,n,2)}function o_(t,n){return _v(t.getFullYear()%1e4,n,4)}function a_(t,n){var e=t.getDay();return _v((t=e>=4||0===e?Ay(t):Ay.ceil(t)).getFullYear()%1e4,n,4)}function u_(t){var n=t.getTimezoneOffset();return(n>0?"-":(n*=-1,"+"))+_v(n/60|0,"0",2)+_v(n%60,"0",2)}function c_(t,n){return _v(t.getUTCDate(),n,2)}function f_(t,n){return _v(t.getUTCHours(),n,2)}function s_(t,n){return _v(t.getUTCHours()%12||12,n,2)}function l_(t,n){return _v(1+yy.count(ev(t),t),n,3)}function h_(t,n){return _v(t.getUTCMilliseconds(),n,3)}function d_(t,n){return h_(t,n)+"000"}function p_(t,n){return _v(t.getUTCMonth()+1,n,2)}function g_(t,n){return _v(t.getUTCMinutes(),n,2)}function y_(t,n){return _v(t.getUTCSeconds(),n,2)}function v_(t){var n=t.getUTCDay();return 0===n?7:n}function __(t,n){return _v(Fy.count(ev(t)-1,t),n,2)}function b_(t){var n=t.getUTCDay();return n>=4||0===n?Oy(t):Oy.ceil(t)}function m_(t,n){return t=b_(t),_v(Oy.count(ev(t),t)+(4===ev(t).getUTCDay()),n,2)}function x_(t){return t.getUTCDay()}function w_(t,n){return _v(qy.count(ev(t)-1,t),n,2)}function M_(t,n){return _v(t.getUTCFullYear()%100,n,2)}function T_(t,n){return _v((t=b_(t)).getUTCFullYear()%100,n,2)}function A_(t,n){return _v(t.getUTCFullYear()%1e4,n,4)}function S_(t,n){var e=t.getUTCDay();return _v((t=e>=4||0===e?Oy(t):Oy.ceil(t)).getUTCFullYear()%1e4,n,4)}function E_(){return"+0000"}function N_(){return"%"}function k_(t){return+t}function C_(t){return Math.floor(+t/1e3)}function P_(n){return dv=hv(n),t.timeFormat=dv.format,t.timeParse=dv.parse,t.utcFormat=dv.utcFormat,t.utcParse=dv.utcParse,dv}t.timeFormat=void 0,t.timeParse=void 0,t.utcFormat=void 0,t.utcParse=void 0,P_({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});var z_="%Y-%m-%dT%H:%M:%S.%LZ";var $_=Date.prototype.toISOString?function(t){return t.toISOString()}:t.utcFormat(z_),D_=$_;var R_=+new Date("2000-01-01T00:00:00.000Z")?function(t){var n=new Date(t);return isNaN(n)?null:n}:t.utcParse(z_),F_=R_;function q_(t){return new Date(t)}function U_(t){return t instanceof Date?+t:+new Date(+t)}function I_(t,n,e,r,i,o,a,u,c,f){var s=Sg(),l=s.invert,h=s.domain,d=f(".%L"),p=f(":%S"),g=f("%I:%M"),y=f("%I %p"),v=f("%a %d"),_=f("%b %d"),b=f("%B"),m=f("%Y");function x(t){return(c(t)Fr(t[t.length-1]),ib=new Array(3).concat("d8b365f5f5f55ab4ac","a6611adfc27d80cdc1018571","a6611adfc27df5f5f580cdc1018571","8c510ad8b365f6e8c3c7eae55ab4ac01665e","8c510ad8b365f6e8c3f5f5f5c7eae55ab4ac01665e","8c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e","8c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e","5430058c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e003c30","5430058c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e003c30").map(H_),ob=rb(ib),ab=new Array(3).concat("af8dc3f7f7f77fbf7b","7b3294c2a5cfa6dba0008837","7b3294c2a5cff7f7f7a6dba0008837","762a83af8dc3e7d4e8d9f0d37fbf7b1b7837","762a83af8dc3e7d4e8f7f7f7d9f0d37fbf7b1b7837","762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b7837","762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b7837","40004b762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b783700441b","40004b762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b783700441b").map(H_),ub=rb(ab),cb=new Array(3).concat("e9a3c9f7f7f7a1d76a","d01c8bf1b6dab8e1864dac26","d01c8bf1b6daf7f7f7b8e1864dac26","c51b7de9a3c9fde0efe6f5d0a1d76a4d9221","c51b7de9a3c9fde0eff7f7f7e6f5d0a1d76a4d9221","c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221","c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221","8e0152c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221276419","8e0152c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221276419").map(H_),fb=rb(cb),sb=new Array(3).concat("998ec3f7f7f7f1a340","5e3c99b2abd2fdb863e66101","5e3c99b2abd2f7f7f7fdb863e66101","542788998ec3d8daebfee0b6f1a340b35806","542788998ec3d8daebf7f7f7fee0b6f1a340b35806","5427888073acb2abd2d8daebfee0b6fdb863e08214b35806","5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b35806","2d004b5427888073acb2abd2d8daebfee0b6fdb863e08214b358067f3b08","2d004b5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b358067f3b08").map(H_),lb=rb(sb),hb=new Array(3).concat("ef8a62f7f7f767a9cf","ca0020f4a58292c5de0571b0","ca0020f4a582f7f7f792c5de0571b0","b2182bef8a62fddbc7d1e5f067a9cf2166ac","b2182bef8a62fddbc7f7f7f7d1e5f067a9cf2166ac","b2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac","b2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac","67001fb2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac053061","67001fb2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac053061").map(H_),db=rb(hb),pb=new Array(3).concat("ef8a62ffffff999999","ca0020f4a582bababa404040","ca0020f4a582ffffffbababa404040","b2182bef8a62fddbc7e0e0e09999994d4d4d","b2182bef8a62fddbc7ffffffe0e0e09999994d4d4d","b2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d","b2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d","67001fb2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d1a1a1a","67001fb2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d1a1a1a").map(H_),gb=rb(pb),yb=new Array(3).concat("fc8d59ffffbf91bfdb","d7191cfdae61abd9e92c7bb6","d7191cfdae61ffffbfabd9e92c7bb6","d73027fc8d59fee090e0f3f891bfdb4575b4","d73027fc8d59fee090ffffbfe0f3f891bfdb4575b4","d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4","d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4","a50026d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4313695","a50026d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4313695").map(H_),vb=rb(yb),_b=new Array(3).concat("fc8d59ffffbf91cf60","d7191cfdae61a6d96a1a9641","d7191cfdae61ffffbfa6d96a1a9641","d73027fc8d59fee08bd9ef8b91cf601a9850","d73027fc8d59fee08bffffbfd9ef8b91cf601a9850","d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850","d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850","a50026d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850006837","a50026d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850006837").map(H_),bb=rb(_b),mb=new Array(3).concat("fc8d59ffffbf99d594","d7191cfdae61abdda42b83ba","d7191cfdae61ffffbfabdda42b83ba","d53e4ffc8d59fee08be6f59899d5943288bd","d53e4ffc8d59fee08bffffbfe6f59899d5943288bd","d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd","d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd","9e0142d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd5e4fa2","9e0142d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd5e4fa2").map(H_),xb=rb(mb),wb=new Array(3).concat("e5f5f999d8c92ca25f","edf8fbb2e2e266c2a4238b45","edf8fbb2e2e266c2a42ca25f006d2c","edf8fbccece699d8c966c2a42ca25f006d2c","edf8fbccece699d8c966c2a441ae76238b45005824","f7fcfde5f5f9ccece699d8c966c2a441ae76238b45005824","f7fcfde5f5f9ccece699d8c966c2a441ae76238b45006d2c00441b").map(H_),Mb=rb(wb),Tb=new Array(3).concat("e0ecf49ebcda8856a7","edf8fbb3cde38c96c688419d","edf8fbb3cde38c96c68856a7810f7c","edf8fbbfd3e69ebcda8c96c68856a7810f7c","edf8fbbfd3e69ebcda8c96c68c6bb188419d6e016b","f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d6e016b","f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d810f7c4d004b").map(H_),Ab=rb(Tb),Sb=new Array(3).concat("e0f3dba8ddb543a2ca","f0f9e8bae4bc7bccc42b8cbe","f0f9e8bae4bc7bccc443a2ca0868ac","f0f9e8ccebc5a8ddb57bccc443a2ca0868ac","f0f9e8ccebc5a8ddb57bccc44eb3d32b8cbe08589e","f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe08589e","f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe0868ac084081").map(H_),Eb=rb(Sb),Nb=new Array(3).concat("fee8c8fdbb84e34a33","fef0d9fdcc8afc8d59d7301f","fef0d9fdcc8afc8d59e34a33b30000","fef0d9fdd49efdbb84fc8d59e34a33b30000","fef0d9fdd49efdbb84fc8d59ef6548d7301f990000","fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301f990000","fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301fb300007f0000").map(H_),kb=rb(Nb),Cb=new Array(3).concat("ece2f0a6bddb1c9099","f6eff7bdc9e167a9cf02818a","f6eff7bdc9e167a9cf1c9099016c59","f6eff7d0d1e6a6bddb67a9cf1c9099016c59","f6eff7d0d1e6a6bddb67a9cf3690c002818a016450","fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016450","fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016c59014636").map(H_),Pb=rb(Cb),zb=new Array(3).concat("ece7f2a6bddb2b8cbe","f1eef6bdc9e174a9cf0570b0","f1eef6bdc9e174a9cf2b8cbe045a8d","f1eef6d0d1e6a6bddb74a9cf2b8cbe045a8d","f1eef6d0d1e6a6bddb74a9cf3690c00570b0034e7b","fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0034e7b","fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0045a8d023858").map(H_),$b=rb(zb),Db=new Array(3).concat("e7e1efc994c7dd1c77","f1eef6d7b5d8df65b0ce1256","f1eef6d7b5d8df65b0dd1c77980043","f1eef6d4b9dac994c7df65b0dd1c77980043","f1eef6d4b9dac994c7df65b0e7298ace125691003f","f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125691003f","f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125698004367001f").map(H_),Rb=rb(Db),Fb=new Array(3).concat("fde0ddfa9fb5c51b8a","feebe2fbb4b9f768a1ae017e","feebe2fbb4b9f768a1c51b8a7a0177","feebe2fcc5c0fa9fb5f768a1c51b8a7a0177","feebe2fcc5c0fa9fb5f768a1dd3497ae017e7a0177","fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a0177","fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a017749006a").map(H_),qb=rb(Fb),Ub=new Array(3).concat("edf8b17fcdbb2c7fb8","ffffcca1dab441b6c4225ea8","ffffcca1dab441b6c42c7fb8253494","ffffccc7e9b47fcdbb41b6c42c7fb8253494","ffffccc7e9b47fcdbb41b6c41d91c0225ea80c2c84","ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea80c2c84","ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea8253494081d58").map(H_),Ib=rb(Ub),Ob=new Array(3).concat("f7fcb9addd8e31a354","ffffccc2e69978c679238443","ffffccc2e69978c67931a354006837","ffffccd9f0a3addd8e78c67931a354006837","ffffccd9f0a3addd8e78c67941ab5d238443005a32","ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443005a32","ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443006837004529").map(H_),Bb=rb(Ob),Yb=new Array(3).concat("fff7bcfec44fd95f0e","ffffd4fed98efe9929cc4c02","ffffd4fed98efe9929d95f0e993404","ffffd4fee391fec44ffe9929d95f0e993404","ffffd4fee391fec44ffe9929ec7014cc4c028c2d04","ffffe5fff7bcfee391fec44ffe9929ec7014cc4c028c2d04","ffffe5fff7bcfee391fec44ffe9929ec7014cc4c02993404662506").map(H_),Lb=rb(Yb),jb=new Array(3).concat("ffeda0feb24cf03b20","ffffb2fecc5cfd8d3ce31a1c","ffffb2fecc5cfd8d3cf03b20bd0026","ffffb2fed976feb24cfd8d3cf03b20bd0026","ffffb2fed976feb24cfd8d3cfc4e2ae31a1cb10026","ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cb10026","ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cbd0026800026").map(H_),Hb=rb(jb),Xb=new Array(3).concat("deebf79ecae13182bd","eff3ffbdd7e76baed62171b5","eff3ffbdd7e76baed63182bd08519c","eff3ffc6dbef9ecae16baed63182bd08519c","eff3ffc6dbef9ecae16baed64292c62171b5084594","f7fbffdeebf7c6dbef9ecae16baed64292c62171b5084594","f7fbffdeebf7c6dbef9ecae16baed64292c62171b508519c08306b").map(H_),Gb=rb(Xb),Vb=new Array(3).concat("e5f5e0a1d99b31a354","edf8e9bae4b374c476238b45","edf8e9bae4b374c47631a354006d2c","edf8e9c7e9c0a1d99b74c47631a354006d2c","edf8e9c7e9c0a1d99b74c47641ab5d238b45005a32","f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45005a32","f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45006d2c00441b").map(H_),Wb=rb(Vb),Zb=new Array(3).concat("f0f0f0bdbdbd636363","f7f7f7cccccc969696525252","f7f7f7cccccc969696636363252525","f7f7f7d9d9d9bdbdbd969696636363252525","f7f7f7d9d9d9bdbdbd969696737373525252252525","fffffff0f0f0d9d9d9bdbdbd969696737373525252252525","fffffff0f0f0d9d9d9bdbdbd969696737373525252252525000000").map(H_),Kb=rb(Zb),Qb=new Array(3).concat("efedf5bcbddc756bb1","f2f0f7cbc9e29e9ac86a51a3","f2f0f7cbc9e29e9ac8756bb154278f","f2f0f7dadaebbcbddc9e9ac8756bb154278f","f2f0f7dadaebbcbddc9e9ac8807dba6a51a34a1486","fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a34a1486","fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a354278f3f007d").map(H_),Jb=rb(Qb),tm=new Array(3).concat("fee0d2fc9272de2d26","fee5d9fcae91fb6a4acb181d","fee5d9fcae91fb6a4ade2d26a50f15","fee5d9fcbba1fc9272fb6a4ade2d26a50f15","fee5d9fcbba1fc9272fb6a4aef3b2ccb181d99000d","fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181d99000d","fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181da50f1567000d").map(H_),nm=rb(tm),em=new Array(3).concat("fee6cefdae6be6550d","feeddefdbe85fd8d3cd94701","feeddefdbe85fd8d3ce6550da63603","feeddefdd0a2fdae6bfd8d3ce6550da63603","feeddefdd0a2fdae6bfd8d3cf16913d948018c2d04","fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d948018c2d04","fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d94801a636037f2704").map(H_),rm=rb(em);var im=hi(Tr(300,.5,0),Tr(-240,.5,1)),om=hi(Tr(-100,.75,.35),Tr(80,1.5,.8)),am=hi(Tr(260,.75,.35),Tr(80,1.5,.8)),um=Tr();var cm=Fe(),fm=Math.PI/3,sm=2*Math.PI/3;function lm(t){var n=t.length;return function(e){return t[Math.max(0,Math.min(n-1,Math.floor(e*n)))]}}var hm=lm(H_("44015444025645045745055946075a46085c460a5d460b5e470d60470e6147106347116447136548146748166848176948186a481a6c481b6d481c6e481d6f481f70482071482173482374482475482576482677482878482979472a7a472c7a472d7b472e7c472f7d46307e46327e46337f463480453581453781453882443983443a83443b84433d84433e85423f854240864241864142874144874045884046883f47883f48893e49893e4a893e4c8a3d4d8a3d4e8a3c4f8a3c508b3b518b3b528b3a538b3a548c39558c39568c38588c38598c375a8c375b8d365c8d365d8d355e8d355f8d34608d34618d33628d33638d32648e32658e31668e31678e31688e30698e306a8e2f6b8e2f6c8e2e6d8e2e6e8e2e6f8e2d708e2d718e2c718e2c728e2c738e2b748e2b758e2a768e2a778e2a788e29798e297a8e297b8e287c8e287d8e277e8e277f8e27808e26818e26828e26828e25838e25848e25858e24868e24878e23888e23898e238a8d228b8d228c8d228d8d218e8d218f8d21908d21918c20928c20928c20938c1f948c1f958b1f968b1f978b1f988b1f998a1f9a8a1e9b8a1e9c891e9d891f9e891f9f881fa0881fa1881fa1871fa28720a38620a48621a58521a68522a78522a88423a98324aa8325ab8225ac8226ad8127ad8128ae8029af7f2ab07f2cb17e2db27d2eb37c2fb47c31b57b32b67a34b67935b77937b87838b9773aba763bbb753dbc743fbc7340bd7242be7144bf7046c06f48c16e4ac16d4cc26c4ec36b50c46a52c56954c56856c66758c7655ac8645cc8635ec96260ca6063cb5f65cb5e67cc5c69cd5b6ccd5a6ece5870cf5773d05675d05477d1537ad1517cd2507fd34e81d34d84d44b86d54989d5488bd6468ed64590d74393d74195d84098d83e9bd93c9dd93ba0da39a2da37a5db36a8db34aadc32addc30b0dd2fb2dd2db5de2bb8de29bade28bddf26c0df25c2df23c5e021c8e020cae11fcde11dd0e11cd2e21bd5e21ad8e219dae319dde318dfe318e2e418e5e419e7e419eae51aece51befe51cf1e51df4e61ef6e620f8e621fbe723fde725")),dm=lm(H_("00000401000501010601010802010902020b02020d03030f03031204041405041606051806051a07061c08071e0907200a08220b09240c09260d0a290e0b2b100b2d110c2f120d31130d34140e36150e38160f3b180f3d19103f1a10421c10441d11471e114920114b21114e22115024125325125527125829115a2a115c2c115f2d11612f116331116533106734106936106b38106c390f6e3b0f703d0f713f0f72400f74420f75440f764510774710784910784a10794c117a4e117b4f127b51127c52137c54137d56147d57157e59157e5a167e5c167f5d177f5f187f601880621980641a80651a80671b80681c816a1c816b1d816d1d816e1e81701f81721f817320817521817621817822817922827b23827c23827e24828025828125818326818426818627818827818928818b29818c29818e2a81902a81912b81932b80942c80962c80982d80992d809b2e7f9c2e7f9e2f7fa02f7fa1307ea3307ea5317ea6317da8327daa337dab337cad347cae347bb0357bb2357bb3367ab5367ab73779b83779ba3878bc3978bd3977bf3a77c03a76c23b75c43c75c53c74c73d73c83e73ca3e72cc3f71cd4071cf4070d0416fd2426fd3436ed5446dd6456cd8456cd9466bdb476adc4869de4968df4a68e04c67e24d66e34e65e44f64e55064e75263e85362e95462ea5661eb5760ec5860ed5a5fee5b5eef5d5ef05f5ef1605df2625df2645cf3655cf4675cf4695cf56b5cf66c5cf66e5cf7705cf7725cf8745cf8765cf9785df9795df97b5dfa7d5efa7f5efa815ffb835ffb8560fb8761fc8961fc8a62fc8c63fc8e64fc9065fd9266fd9467fd9668fd9869fd9a6afd9b6bfe9d6cfe9f6dfea16efea36ffea571fea772fea973feaa74feac76feae77feb078feb27afeb47bfeb67cfeb77efeb97ffebb81febd82febf84fec185fec287fec488fec68afec88cfeca8dfecc8ffecd90fecf92fed194fed395fed597fed799fed89afdda9cfddc9efddea0fde0a1fde2a3fde3a5fde5a7fde7a9fde9aafdebacfcecaefceeb0fcf0b2fcf2b4fcf4b6fcf6b8fcf7b9fcf9bbfcfbbdfcfdbf")),pm=lm(H_("00000401000501010601010802010a02020c02020e03021004031204031405041706041907051b08051d09061f0a07220b07240c08260d08290e092b10092d110a30120a32140b34150b37160b39180c3c190c3e1b0c411c0c431e0c451f0c48210c4a230c4c240c4f260c51280b53290b552b0b572d0b592f0a5b310a5c320a5e340a5f3609613809623909633b09643d09653e0966400a67420a68440a68450a69470b6a490b6a4a0c6b4c0c6b4d0d6c4f0d6c510e6c520e6d540f6d550f6d57106e59106e5a116e5c126e5d126e5f136e61136e62146e64156e65156e67166e69166e6a176e6c186e6d186e6f196e71196e721a6e741a6e751b6e771c6d781c6d7a1d6d7c1d6d7d1e6d7f1e6c801f6c82206c84206b85216b87216b88226a8a226a8c23698d23698f24699025689225689326679526679727669827669a28659b29649d29649f2a63a02a63a22b62a32c61a52c60a62d60a82e5fa92e5eab2f5ead305dae305cb0315bb1325ab3325ab43359b63458b73557b93556ba3655bc3754bd3853bf3952c03a51c13a50c33b4fc43c4ec63d4dc73e4cc83f4bca404acb4149cc4248ce4347cf4446d04545d24644d34743d44842d54a41d74b3fd84c3ed94d3dda4e3cdb503bdd513ade5238df5337e05536e15635e25734e35933e45a31e55c30e65d2fe75e2ee8602de9612bea632aeb6429eb6628ec6726ed6925ee6a24ef6c23ef6e21f06f20f1711ff1731df2741cf3761bf37819f47918f57b17f57d15f67e14f68013f78212f78410f8850ff8870ef8890cf98b0bf98c0af98e09fa9008fa9207fa9407fb9606fb9706fb9906fb9b06fb9d07fc9f07fca108fca309fca50afca60cfca80dfcaa0ffcac11fcae12fcb014fcb216fcb418fbb61afbb81dfbba1ffbbc21fbbe23fac026fac228fac42afac62df9c72ff9c932f9cb35f8cd37f8cf3af7d13df7d340f6d543f6d746f5d949f5db4cf4dd4ff4df53f4e156f3e35af3e55df2e661f2e865f2ea69f1ec6df1ed71f1ef75f1f179f2f27df2f482f3f586f3f68af4f88ef5f992f6fa96f8fb9af9fc9dfafda1fcffa4")),gm=lm(H_("0d088710078813078916078a19068c1b068d1d068e20068f2206902406912605912805922a05932c05942e05952f059631059733059735049837049938049a3a049a3c049b3e049c3f049c41049d43039e44039e46039f48039f4903a04b03a14c02a14e02a25002a25102a35302a35502a45601a45801a45901a55b01a55c01a65e01a66001a66100a76300a76400a76600a76700a86900a86a00a86c00a86e00a86f00a87100a87201a87401a87501a87701a87801a87a02a87b02a87d03a87e03a88004a88104a78305a78405a78606a68707a68808a68a09a58b0aa58d0ba58e0ca48f0da4910ea3920fa39410a29511a19613a19814a099159f9a169f9c179e9d189d9e199da01a9ca11b9ba21d9aa31e9aa51f99a62098a72197a82296aa2395ab2494ac2694ad2793ae2892b02991b12a90b22b8fb32c8eb42e8db52f8cb6308bb7318ab83289ba3388bb3488bc3587bd3786be3885bf3984c03a83c13b82c23c81c33d80c43e7fc5407ec6417dc7427cc8437bc9447aca457acb4679cc4778cc4977cd4a76ce4b75cf4c74d04d73d14e72d24f71d35171d45270d5536fd5546ed6556dd7566cd8576bd9586ada5a6ada5b69db5c68dc5d67dd5e66de5f65de6164df6263e06363e16462e26561e26660e3685fe4695ee56a5de56b5de66c5ce76e5be76f5ae87059e97158e97257ea7457eb7556eb7655ec7754ed7953ed7a52ee7b51ef7c51ef7e50f07f4ff0804ef1814df1834cf2844bf3854bf3874af48849f48948f58b47f58c46f68d45f68f44f79044f79143f79342f89441f89540f9973ff9983ef99a3efa9b3dfa9c3cfa9e3bfb9f3afba139fba238fca338fca537fca636fca835fca934fdab33fdac33fdae32fdaf31fdb130fdb22ffdb42ffdb52efeb72dfeb82cfeba2cfebb2bfebd2afebe2afec029fdc229fdc328fdc527fdc627fdc827fdca26fdcb26fccd25fcce25fcd025fcd225fbd324fbd524fbd724fad824fada24f9dc24f9dd25f8df25f8e125f7e225f7e425f6e626f6e826f5e926f5eb27f4ed27f3ee27f3f027f2f227f1f426f1f525f0f724f0f921"));function ym(t){return function(){return t}}const vm=Math.abs,_m=Math.atan2,bm=Math.cos,mm=Math.max,xm=Math.min,wm=Math.sin,Mm=Math.sqrt,Tm=1e-12,Am=Math.PI,Sm=Am/2,Em=2*Am;function Nm(t){return t>=1?Sm:t<=-1?-Sm:Math.asin(t)}function km(t){let n=3;return t.digits=function(e){if(!arguments.length)return n;if(null==e)n=null;else{const t=Math.floor(e);if(!(t>=0))throw new RangeError(`invalid digits: ${e}`);n=t}return t},()=>new Ua(n)}function Cm(t){return t.innerRadius}function Pm(t){return t.outerRadius}function zm(t){return t.startAngle}function $m(t){return t.endAngle}function Dm(t){return t&&t.padAngle}function Rm(t,n,e,r,i,o,a){var u=t-e,c=n-r,f=(a?o:-o)/Mm(u*u+c*c),s=f*c,l=-f*u,h=t+s,d=n+l,p=e+s,g=r+l,y=(h+p)/2,v=(d+g)/2,_=p-h,b=g-d,m=_*_+b*b,x=i-o,w=h*g-p*d,M=(b<0?-1:1)*Mm(mm(0,x*x*m-w*w)),T=(w*b-_*M)/m,A=(-w*_-b*M)/m,S=(w*b+_*M)/m,E=(-w*_+b*M)/m,N=T-y,k=A-v,C=S-y,P=E-v;return N*N+k*k>C*C+P*P&&(T=S,A=E),{cx:T,cy:A,x01:-s,y01:-l,x11:T*(i/x-1),y11:A*(i/x-1)}}var Fm=Array.prototype.slice;function qm(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}function Um(t){this._context=t}function Im(t){return new Um(t)}function Om(t){return t[0]}function Bm(t){return t[1]}function Ym(t,n){var e=ym(!0),r=null,i=Im,o=null,a=km(u);function u(u){var c,f,s,l=(u=qm(u)).length,h=!1;for(null==r&&(o=i(s=a())),c=0;c<=l;++c)!(c=l;--h)u.point(v[h],_[h]);u.lineEnd(),u.areaEnd()}y&&(v[s]=+t(d,s,f),_[s]=+n(d,s,f),u.point(r?+r(d,s,f):v[s],e?+e(d,s,f):_[s]))}if(p)return u=null,p+""||null}function s(){return Ym().defined(i).curve(a).context(o)}return t="function"==typeof t?t:void 0===t?Om:ym(+t),n="function"==typeof n?n:ym(void 0===n?0:+n),e="function"==typeof e?e:void 0===e?Bm:ym(+e),f.x=function(n){return arguments.length?(t="function"==typeof n?n:ym(+n),r=null,f):t},f.x0=function(n){return arguments.length?(t="function"==typeof n?n:ym(+n),f):t},f.x1=function(t){return arguments.length?(r=null==t?null:"function"==typeof t?t:ym(+t),f):r},f.y=function(t){return arguments.length?(n="function"==typeof t?t:ym(+t),e=null,f):n},f.y0=function(t){return arguments.length?(n="function"==typeof t?t:ym(+t),f):n},f.y1=function(t){return arguments.length?(e=null==t?null:"function"==typeof t?t:ym(+t),f):e},f.lineX0=f.lineY0=function(){return s().x(t).y(n)},f.lineY1=function(){return s().x(t).y(e)},f.lineX1=function(){return s().x(r).y(n)},f.defined=function(t){return arguments.length?(i="function"==typeof t?t:ym(!!t),f):i},f.curve=function(t){return arguments.length?(a=t,null!=o&&(u=a(o)),f):a},f.context=function(t){return arguments.length?(null==t?o=u=null:u=a(o=t),f):o},f}function jm(t,n){return nt?1:n>=t?0:NaN}function Hm(t){return t}Um.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:this._context.lineTo(t,n)}}};var Xm=Vm(Im);function Gm(t){this._curve=t}function Vm(t){function n(n){return new Gm(t(n))}return n._curve=t,n}function Wm(t){var n=t.curve;return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t.curve=function(t){return arguments.length?n(Vm(t)):n()._curve},t}function Zm(){return Wm(Ym().curve(Xm))}function Km(){var t=Lm().curve(Xm),n=t.curve,e=t.lineX0,r=t.lineX1,i=t.lineY0,o=t.lineY1;return t.angle=t.x,delete t.x,t.startAngle=t.x0,delete t.x0,t.endAngle=t.x1,delete t.x1,t.radius=t.y,delete t.y,t.innerRadius=t.y0,delete t.y0,t.outerRadius=t.y1,delete t.y1,t.lineStartAngle=function(){return Wm(e())},delete t.lineX0,t.lineEndAngle=function(){return Wm(r())},delete t.lineX1,t.lineInnerRadius=function(){return Wm(i())},delete t.lineY0,t.lineOuterRadius=function(){return Wm(o())},delete t.lineY1,t.curve=function(t){return arguments.length?n(Vm(t)):n()._curve},t}function Qm(t,n){return[(n=+n)*Math.cos(t-=Math.PI/2),n*Math.sin(t)]}Gm.prototype={areaStart:function(){this._curve.areaStart()},areaEnd:function(){this._curve.areaEnd()},lineStart:function(){this._curve.lineStart()},lineEnd:function(){this._curve.lineEnd()},point:function(t,n){this._curve.point(n*Math.sin(t),n*-Math.cos(t))}};class Jm{constructor(t,n){this._context=t,this._x=n}areaStart(){this._line=0}areaEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line}point(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:this._x?this._context.bezierCurveTo(this._x0=(this._x0+t)/2,this._y0,this._x0,n,t,n):this._context.bezierCurveTo(this._x0,this._y0=(this._y0+n)/2,t,this._y0,t,n)}this._x0=t,this._y0=n}}class tx{constructor(t){this._context=t}lineStart(){this._point=0}lineEnd(){}point(t,n){if(t=+t,n=+n,0===this._point)this._point=1;else{const e=Qm(this._x0,this._y0),r=Qm(this._x0,this._y0=(this._y0+n)/2),i=Qm(t,this._y0),o=Qm(t,n);this._context.moveTo(...e),this._context.bezierCurveTo(...r,...i,...o)}this._x0=t,this._y0=n}}function nx(t){return new Jm(t,!0)}function ex(t){return new Jm(t,!1)}function rx(t){return new tx(t)}function ix(t){return t.source}function ox(t){return t.target}function ax(t){let n=ix,e=ox,r=Om,i=Bm,o=null,a=null,u=km(c);function c(){let c;const f=Fm.call(arguments),s=n.apply(this,f),l=e.apply(this,f);if(null==o&&(a=t(c=u())),a.lineStart(),f[0]=s,a.point(+r.apply(this,f),+i.apply(this,f)),f[0]=l,a.point(+r.apply(this,f),+i.apply(this,f)),a.lineEnd(),c)return a=null,c+""||null}return c.source=function(t){return arguments.length?(n=t,c):n},c.target=function(t){return arguments.length?(e=t,c):e},c.x=function(t){return arguments.length?(r="function"==typeof t?t:ym(+t),c):r},c.y=function(t){return arguments.length?(i="function"==typeof t?t:ym(+t),c):i},c.context=function(n){return arguments.length?(null==n?o=a=null:a=t(o=n),c):o},c}const ux=Mm(3);var cx={draw(t,n){const e=.59436*Mm(n+xm(n/28,.75)),r=e/2,i=r*ux;t.moveTo(0,e),t.lineTo(0,-e),t.moveTo(-i,-r),t.lineTo(i,r),t.moveTo(-i,r),t.lineTo(i,-r)}},fx={draw(t,n){const e=Mm(n/Am);t.moveTo(e,0),t.arc(0,0,e,0,Em)}},sx={draw(t,n){const e=Mm(n/5)/2;t.moveTo(-3*e,-e),t.lineTo(-e,-e),t.lineTo(-e,-3*e),t.lineTo(e,-3*e),t.lineTo(e,-e),t.lineTo(3*e,-e),t.lineTo(3*e,e),t.lineTo(e,e),t.lineTo(e,3*e),t.lineTo(-e,3*e),t.lineTo(-e,e),t.lineTo(-3*e,e),t.closePath()}};const lx=Mm(1/3),hx=2*lx;var dx={draw(t,n){const e=Mm(n/hx),r=e*lx;t.moveTo(0,-e),t.lineTo(r,0),t.lineTo(0,e),t.lineTo(-r,0),t.closePath()}},px={draw(t,n){const e=.62625*Mm(n);t.moveTo(0,-e),t.lineTo(e,0),t.lineTo(0,e),t.lineTo(-e,0),t.closePath()}},gx={draw(t,n){const e=.87559*Mm(n-xm(n/7,2));t.moveTo(-e,0),t.lineTo(e,0),t.moveTo(0,e),t.lineTo(0,-e)}},yx={draw(t,n){const e=Mm(n),r=-e/2;t.rect(r,r,e,e)}},vx={draw(t,n){const e=.4431*Mm(n);t.moveTo(e,e),t.lineTo(e,-e),t.lineTo(-e,-e),t.lineTo(-e,e),t.closePath()}};const _x=wm(Am/10)/wm(7*Am/10),bx=wm(Em/10)*_x,mx=-bm(Em/10)*_x;var xx={draw(t,n){const e=Mm(.8908130915292852*n),r=bx*e,i=mx*e;t.moveTo(0,-e),t.lineTo(r,i);for(let n=1;n<5;++n){const o=Em*n/5,a=bm(o),u=wm(o);t.lineTo(u*e,-a*e),t.lineTo(a*r-u*i,u*r+a*i)}t.closePath()}};const wx=Mm(3);var Mx={draw(t,n){const e=-Mm(n/(3*wx));t.moveTo(0,2*e),t.lineTo(-wx*e,-e),t.lineTo(wx*e,-e),t.closePath()}};const Tx=Mm(3);var Ax={draw(t,n){const e=.6824*Mm(n),r=e/2,i=e*Tx/2;t.moveTo(0,-e),t.lineTo(i,r),t.lineTo(-i,r),t.closePath()}};const Sx=-.5,Ex=Mm(3)/2,Nx=1/Mm(12),kx=3*(Nx/2+1);var Cx={draw(t,n){const e=Mm(n/kx),r=e/2,i=e*Nx,o=r,a=e*Nx+e,u=-o,c=a;t.moveTo(r,i),t.lineTo(o,a),t.lineTo(u,c),t.lineTo(Sx*r-Ex*i,Ex*r+Sx*i),t.lineTo(Sx*o-Ex*a,Ex*o+Sx*a),t.lineTo(Sx*u-Ex*c,Ex*u+Sx*c),t.lineTo(Sx*r+Ex*i,Sx*i-Ex*r),t.lineTo(Sx*o+Ex*a,Sx*a-Ex*o),t.lineTo(Sx*u+Ex*c,Sx*c-Ex*u),t.closePath()}},Px={draw(t,n){const e=.6189*Mm(n-xm(n/6,1.7));t.moveTo(-e,-e),t.lineTo(e,e),t.moveTo(-e,e),t.lineTo(e,-e)}};const zx=[fx,sx,dx,yx,xx,Mx,Cx],$x=[fx,gx,Px,Ax,cx,vx,px];function Dx(){}function Rx(t,n,e){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+n)/6,(t._y0+4*t._y1+e)/6)}function Fx(t){this._context=t}function qx(t){this._context=t}function Ux(t){this._context=t}function Ix(t,n){this._basis=new Fx(t),this._beta=n}Fx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){switch(this._point){case 3:Rx(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:Rx(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},qx.prototype={areaStart:Dx,areaEnd:Dx,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x2,this._y2),this._context.closePath();break;case 2:this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break;case 3:this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4)}},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._x2=t,this._y2=n;break;case 1:this._point=2,this._x3=t,this._y3=n;break;case 2:this._point=3,this._x4=t,this._y4=n,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+n)/6);break;default:Rx(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},Ux.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var e=(this._x0+4*this._x1+t)/6,r=(this._y0+4*this._y1+n)/6;this._line?this._context.lineTo(e,r):this._context.moveTo(e,r);break;case 3:this._point=4;default:Rx(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},Ix.prototype={lineStart:function(){this._x=[],this._y=[],this._basis.lineStart()},lineEnd:function(){var t=this._x,n=this._y,e=t.length-1;if(e>0)for(var r,i=t[0],o=n[0],a=t[e]-i,u=n[e]-o,c=-1;++c<=e;)r=c/e,this._basis.point(this._beta*t[c]+(1-this._beta)*(i+r*a),this._beta*n[c]+(1-this._beta)*(o+r*u));this._x=this._y=null,this._basis.lineEnd()},point:function(t,n){this._x.push(+t),this._y.push(+n)}};var Ox=function t(n){function e(t){return 1===n?new Fx(t):new Ix(t,n)}return e.beta=function(n){return t(+n)},e}(.85);function Bx(t,n,e){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-n),t._y2+t._k*(t._y1-e),t._x2,t._y2)}function Yx(t,n){this._context=t,this._k=(1-n)/6}Yx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:Bx(this,this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2,this._x1=t,this._y1=n;break;case 2:this._point=3;default:Bx(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Lx=function t(n){function e(t){return new Yx(t,n)}return e.tension=function(n){return t(+n)},e}(0);function jx(t,n){this._context=t,this._k=(1-n)/6}jx.prototype={areaStart:Dx,areaEnd:Dx,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._x3=t,this._y3=n;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3,this._x5=t,this._y5=n;break;default:Bx(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Hx=function t(n){function e(t){return new jx(t,n)}return e.tension=function(n){return t(+n)},e}(0);function Xx(t,n){this._context=t,this._k=(1-n)/6}Xx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Bx(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Gx=function t(n){function e(t){return new Xx(t,n)}return e.tension=function(n){return t(+n)},e}(0);function Vx(t,n,e){var r=t._x1,i=t._y1,o=t._x2,a=t._y2;if(t._l01_a>Tm){var u=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,c=3*t._l01_a*(t._l01_a+t._l12_a);r=(r*u-t._x0*t._l12_2a+t._x2*t._l01_2a)/c,i=(i*u-t._y0*t._l12_2a+t._y2*t._l01_2a)/c}if(t._l23_a>Tm){var f=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,s=3*t._l23_a*(t._l23_a+t._l12_a);o=(o*f+t._x1*t._l23_2a-n*t._l12_2a)/s,a=(a*f+t._y1*t._l23_2a-e*t._l12_2a)/s}t._context.bezierCurveTo(r,i,o,a,t._x2,t._y2)}function Wx(t,n){this._context=t,this._alpha=n}Wx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3;default:Vx(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Zx=function t(n){function e(t){return n?new Wx(t,n):new Yx(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function Kx(t,n){this._context=t,this._alpha=n}Kx.prototype={areaStart:Dx,areaEnd:Dx,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._x3=t,this._y3=n;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3,this._x5=t,this._y5=n;break;default:Vx(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Qx=function t(n){function e(t){return n?new Kx(t,n):new jx(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function Jx(t,n){this._context=t,this._alpha=n}Jx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Vx(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var tw=function t(n){function e(t){return n?new Jx(t,n):new Xx(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function nw(t){this._context=t}function ew(t){return t<0?-1:1}function rw(t,n,e){var r=t._x1-t._x0,i=n-t._x1,o=(t._y1-t._y0)/(r||i<0&&-0),a=(e-t._y1)/(i||r<0&&-0),u=(o*i+a*r)/(r+i);return(ew(o)+ew(a))*Math.min(Math.abs(o),Math.abs(a),.5*Math.abs(u))||0}function iw(t,n){var e=t._x1-t._x0;return e?(3*(t._y1-t._y0)/e-n)/2:n}function ow(t,n,e){var r=t._x0,i=t._y0,o=t._x1,a=t._y1,u=(o-r)/3;t._context.bezierCurveTo(r+u,i+u*n,o-u,a-u*e,o,a)}function aw(t){this._context=t}function uw(t){this._context=new cw(t)}function cw(t){this._context=t}function fw(t){this._context=t}function sw(t){var n,e,r=t.length-1,i=new Array(r),o=new Array(r),a=new Array(r);for(i[0]=0,o[0]=2,a[0]=t[0]+2*t[1],n=1;n=0;--n)i[n]=(a[n]-i[n+1])/o[n];for(o[r-1]=(t[r]+i[r-1])/2,n=0;n1)for(var e,r,i,o=1,a=t[n[0]],u=a.length;o=0;)e[n]=n;return e}function pw(t,n){return t[n]}function gw(t){const n=[];return n.key=t,n}function yw(t){var n=t.map(vw);return dw(t).sort((function(t,e){return n[t]-n[e]}))}function vw(t){for(var n,e=-1,r=0,i=t.length,o=-1/0;++eo&&(o=n,r=e);return r}function _w(t){var n=t.map(bw);return dw(t).sort((function(t,e){return n[t]-n[e]}))}function bw(t){for(var n,e=0,r=-1,i=t.length;++r=0&&(this._t=1-this._t,this._line=1-this._line)},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:if(this._t<=0)this._context.lineTo(this._x,n),this._context.lineTo(t,n);else{var e=this._x*(1-this._t)+t*this._t;this._context.lineTo(e,this._y),this._context.lineTo(e,n)}}this._x=t,this._y=n}};var mw=t=>()=>t;function xw(t,{sourceEvent:n,target:e,transform:r,dispatch:i}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:n,enumerable:!0,configurable:!0},target:{value:e,enumerable:!0,configurable:!0},transform:{value:r,enumerable:!0,configurable:!0},_:{value:i}})}function ww(t,n,e){this.k=t,this.x=n,this.y=e}ww.prototype={constructor:ww,scale:function(t){return 1===t?this:new ww(this.k*t,this.x,this.y)},translate:function(t,n){return 0===t&0===n?this:new ww(this.k,this.x+this.k*t,this.y+this.k*n)},apply:function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},applyX:function(t){return t*this.k+this.x},applyY:function(t){return t*this.k+this.y},invert:function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},invertX:function(t){return(t-this.x)/this.k},invertY:function(t){return(t-this.y)/this.k},rescaleX:function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},rescaleY:function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},toString:function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"}};var Mw=new ww(1,0,0);function Tw(t){for(;!t.__zoom;)if(!(t=t.parentNode))return Mw;return t.__zoom}function Aw(t){t.stopImmediatePropagation()}function Sw(t){t.preventDefault(),t.stopImmediatePropagation()}function Ew(t){return!(t.ctrlKey&&"wheel"!==t.type||t.button)}function Nw(){var t=this;return t instanceof SVGElement?(t=t.ownerSVGElement||t).hasAttribute("viewBox")?[[(t=t.viewBox.baseVal).x,t.y],[t.x+t.width,t.y+t.height]]:[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]:[[0,0],[t.clientWidth,t.clientHeight]]}function kw(){return this.__zoom||Mw}function Cw(t){return-t.deltaY*(1===t.deltaMode?.05:t.deltaMode?1:.002)*(t.ctrlKey?10:1)}function Pw(){return navigator.maxTouchPoints||"ontouchstart"in this}function zw(t,n,e){var r=t.invertX(n[0][0])-e[0][0],i=t.invertX(n[1][0])-e[1][0],o=t.invertY(n[0][1])-e[0][1],a=t.invertY(n[1][1])-e[1][1];return t.translate(i>r?(r+i)/2:Math.min(0,r)||Math.max(0,i),a>o?(o+a)/2:Math.min(0,o)||Math.max(0,a))}Tw.prototype=ww.prototype,t.Adder=T,t.Delaunay=Lu,t.FormatSpecifier=tf,t.InternMap=InternMap,t.InternSet=InternSet,t.Node=Qd,t.Path=Ua,t.Voronoi=qu,t.ZoomTransform=ww,t.active=function(t,n){var e,r,i=t.__transition;if(i)for(r in n=null==n?null:n+"",i)if((e=i[r]).state>qi&&e.name===n)return new po([[t]],Zo,n,+r);return null},t.arc=function(){var t=Cm,n=Pm,e=ym(0),r=null,i=zm,o=$m,a=Dm,u=null,c=km(f);function f(){var f,s,l=+t.apply(this,arguments),h=+n.apply(this,arguments),d=i.apply(this,arguments)-Sm,p=o.apply(this,arguments)-Sm,g=vm(p-d),y=p>d;if(u||(u=f=c()),hTm)if(g>Em-Tm)u.moveTo(h*bm(d),h*wm(d)),u.arc(0,0,h,d,p,!y),l>Tm&&(u.moveTo(l*bm(p),l*wm(p)),u.arc(0,0,l,p,d,y));else{var v,_,b=d,m=p,x=d,w=p,M=g,T=g,A=a.apply(this,arguments)/2,S=A>Tm&&(r?+r.apply(this,arguments):Mm(l*l+h*h)),E=xm(vm(h-l)/2,+e.apply(this,arguments)),N=E,k=E;if(S>Tm){var C=Nm(S/l*wm(A)),P=Nm(S/h*wm(A));(M-=2*C)>Tm?(x+=C*=y?1:-1,w-=C):(M=0,x=w=(d+p)/2),(T-=2*P)>Tm?(b+=P*=y?1:-1,m-=P):(T=0,b=m=(d+p)/2)}var z=h*bm(b),$=h*wm(b),D=l*bm(w),R=l*wm(w);if(E>Tm){var F,q=h*bm(m),U=h*wm(m),I=l*bm(x),O=l*wm(x);if(g1?0:t<-1?Am:Math.acos(t)}((B*L+Y*j)/(Mm(B*B+Y*Y)*Mm(L*L+j*j)))/2),X=Mm(F[0]*F[0]+F[1]*F[1]);N=xm(E,(l-X)/(H-1)),k=xm(E,(h-X)/(H+1))}else N=k=0}T>Tm?k>Tm?(v=Rm(I,O,z,$,h,k,y),_=Rm(q,U,D,R,h,k,y),u.moveTo(v.cx+v.x01,v.cy+v.y01),kTm&&M>Tm?N>Tm?(v=Rm(D,R,q,U,l,-N,y),_=Rm(z,$,I,O,l,-N,y),u.lineTo(v.cx+v.x01,v.cy+v.y01),N=0))throw new RangeError("invalid r");let e=t.length;if(!((e=Math.floor(e))>=0))throw new RangeError("invalid length");if(!e||!n)return t;const r=y(n),i=t.slice();return r(t,i,0,e,1),r(i,t,0,e,1),r(t,i,0,e,1),t},t.blur2=l,t.blurImage=h,t.brush=function(){return wa(la)},t.brushSelection=function(t){var n=t.__brush;return n?n.dim.output(n.selection):null},t.brushX=function(){return wa(fa)},t.brushY=function(){return wa(sa)},t.buffer=function(t,n){return fetch(t,n).then(_c)},t.chord=function(){return za(!1,!1)},t.chordDirected=function(){return za(!0,!1)},t.chordTranspose=function(){return za(!1,!0)},t.cluster=function(){var t=Ld,n=1,e=1,r=!1;function i(i){var o,a=0;i.eachAfter((function(n){var e=n.children;e?(n.x=function(t){return t.reduce(jd,0)/t.length}(e),n.y=function(t){return 1+t.reduce(Hd,0)}(e)):(n.x=o?a+=t(n,o):0,n.y=0,o=n)}));var u=function(t){for(var n;n=t.children;)t=n[0];return t}(i),c=function(t){for(var n;n=t.children;)t=n[n.length-1];return t}(i),f=u.x-t(u,c)/2,s=c.x+t(c,u)/2;return i.eachAfter(r?function(t){t.x=(t.x-i.x)*n,t.y=(i.y-t.y)*e}:function(t){t.x=(t.x-f)/(s-f)*n,t.y=(1-(i.y?t.y/i.y:1))*e})}return i.separation=function(n){return arguments.length?(t=n,i):t},i.size=function(t){return arguments.length?(r=!1,n=+t[0],e=+t[1],i):r?null:[n,e]},i.nodeSize=function(t){return arguments.length?(r=!0,n=+t[0],e=+t[1],i):r?[n,e]:null},i},t.color=ze,t.contourDensity=function(){var t=fu,n=su,e=lu,r=960,i=500,o=20,a=2,u=3*o,c=r+2*u>>a,f=i+2*u>>a,s=Qa(20);function h(r){var i=new Float32Array(c*f),s=Math.pow(2,-a),h=-1;for(const o of r){var d=(t(o,++h,r)+u)*s,p=(n(o,h,r)+u)*s,g=+e(o,h,r);if(g&&d>=0&&d=0&&pt*r)))(n).map(((t,n)=>(t.value=+e[n],p(t))))}function p(t){return t.coordinates.forEach(g),t}function g(t){t.forEach(y)}function y(t){t.forEach(v)}function v(t){t[0]=t[0]*Math.pow(2,a)-u,t[1]=t[1]*Math.pow(2,a)-u}function _(){return c=r+2*(u=3*o)>>a,f=i+2*u>>a,d}return d.contours=function(t){var n=h(t),e=iu().size([c,f]),r=Math.pow(2,2*a),i=t=>{t=+t;var i=p(e.contour(n,t*r));return i.value=t,i};return Object.defineProperty(i,"max",{get:()=>J(n)/r}),i},d.x=function(n){return arguments.length?(t="function"==typeof n?n:Qa(+n),d):t},d.y=function(t){return arguments.length?(n="function"==typeof t?t:Qa(+t),d):n},d.weight=function(t){return arguments.length?(e="function"==typeof t?t:Qa(+t),d):e},d.size=function(t){if(!arguments.length)return[r,i];var n=+t[0],e=+t[1];if(!(n>=0&&e>=0))throw new Error("invalid size");return r=n,i=e,_()},d.cellSize=function(t){if(!arguments.length)return 1<=1))throw new Error("invalid cell size");return a=Math.floor(Math.log(t)/Math.LN2),_()},d.thresholds=function(t){return arguments.length?(s="function"==typeof t?t:Array.isArray(t)?Qa(Za.call(t)):Qa(t),d):s},d.bandwidth=function(t){if(!arguments.length)return Math.sqrt(o*(o+1));if(!((t=+t)>=0))throw new Error("invalid bandwidth");return o=(Math.sqrt(4*t*t+1)-1)/2,_()},d},t.contours=iu,t.count=v,t.create=function(t){return Zn(Yt(t).call(document.documentElement))},t.creator=Yt,t.cross=function(...t){const n="function"==typeof t[t.length-1]&&function(t){return n=>t(...n)}(t.pop()),e=(t=t.map(m)).map(_),r=t.length-1,i=new Array(r+1).fill(0),o=[];if(r<0||e.some(b))return o;for(;;){o.push(i.map(((n,e)=>t[e][n])));let a=r;for(;++i[a]===e[a];){if(0===a)return n?o.map(n):o;i[a--]=0}}},t.csv=wc,t.csvFormat=rc,t.csvFormatBody=ic,t.csvFormatRow=ac,t.csvFormatRows=oc,t.csvFormatValue=uc,t.csvParse=nc,t.csvParseRows=ec,t.cubehelix=Tr,t.cumsum=function(t,n){var e=0,r=0;return Float64Array.from(t,void 0===n?t=>e+=+t||0:i=>e+=+n(i,r++,t)||0)},t.curveBasis=function(t){return new Fx(t)},t.curveBasisClosed=function(t){return new qx(t)},t.curveBasisOpen=function(t){return new Ux(t)},t.curveBumpX=nx,t.curveBumpY=ex,t.curveBundle=Ox,t.curveCardinal=Lx,t.curveCardinalClosed=Hx,t.curveCardinalOpen=Gx,t.curveCatmullRom=Zx,t.curveCatmullRomClosed=Qx,t.curveCatmullRomOpen=tw,t.curveLinear=Im,t.curveLinearClosed=function(t){return new nw(t)},t.curveMonotoneX=function(t){return new aw(t)},t.curveMonotoneY=function(t){return new uw(t)},t.curveNatural=function(t){return new fw(t)},t.curveStep=function(t){return new lw(t,.5)},t.curveStepAfter=function(t){return new lw(t,1)},t.curveStepBefore=function(t){return new lw(t,0)},t.descending=e,t.deviation=w,t.difference=function(t,...n){t=new InternSet(t);for(const e of n)for(const n of e)t.delete(n);return t},t.disjoint=function(t,n){const e=n[Symbol.iterator](),r=new InternSet;for(const n of t){if(r.has(n))return!1;let t,i;for(;({value:t,done:i}=e.next())&&!i;){if(Object.is(n,t))return!1;r.add(t)}}return!0},t.dispatch=$t,t.drag=function(){var t,n,e,r,i=se,o=le,a=he,u=de,c={},f=$t("start","drag","end"),s=0,l=0;function h(t){t.on("mousedown.drag",d).filter(u).on("touchstart.drag",y).on("touchmove.drag",v,ee).on("touchend.drag touchcancel.drag",_).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function d(a,u){if(!r&&i.call(this,a,u)){var c=b(this,o.call(this,a,u),a,u,"mouse");c&&(Zn(a.view).on("mousemove.drag",p,re).on("mouseup.drag",g,re),ae(a.view),ie(a),e=!1,t=a.clientX,n=a.clientY,c("start",a))}}function p(r){if(oe(r),!e){var i=r.clientX-t,o=r.clientY-n;e=i*i+o*o>l}c.mouse("drag",r)}function g(t){Zn(t.view).on("mousemove.drag mouseup.drag",null),ue(t.view,e),oe(t),c.mouse("end",t)}function y(t,n){if(i.call(this,t,n)){var e,r,a=t.changedTouches,u=o.call(this,t,n),c=a.length;for(e=0;e+t,t.easePoly=wo,t.easePolyIn=mo,t.easePolyInOut=wo,t.easePolyOut=xo,t.easeQuad=_o,t.easeQuadIn=function(t){return t*t},t.easeQuadInOut=_o,t.easeQuadOut=function(t){return t*(2-t)},t.easeSin=Ao,t.easeSinIn=function(t){return 1==+t?1:1-Math.cos(t*To)},t.easeSinInOut=Ao,t.easeSinOut=function(t){return Math.sin(t*To)},t.every=function(t,n){if("function"!=typeof n)throw new TypeError("test is not a function");let e=-1;for(const r of t)if(!n(r,++e,t))return!1;return!0},t.extent=M,t.fcumsum=function(t,n){const e=new T;let r=-1;return Float64Array.from(t,void 0===n?t=>e.add(+t||0):i=>e.add(+n(i,++r,t)||0))},t.filter=function(t,n){if("function"!=typeof n)throw new TypeError("test is not a function");const e=[];let r=-1;for(const i of t)n(i,++r,t)&&e.push(i);return e},t.flatGroup=function(t,...n){return z(P(t,...n),n)},t.flatRollup=function(t,n,...e){return z(D(t,n,...e),e)},t.forceCenter=function(t,n){var e,r=1;function i(){var i,o,a=e.length,u=0,c=0;for(i=0;if+p||os+p||ac.index){var g=f-u.x-u.vx,y=s-u.y-u.vy,v=g*g+y*y;vt.r&&(t.r=t[n].r)}function c(){if(n){var r,i,o=n.length;for(e=new Array(o),r=0;r[u(t,n,r),t])));for(a=0,i=new Array(f);a=u)){(t.data!==n||t.next)&&(0===l&&(p+=(l=Uc(e))*l),0===h&&(p+=(h=Uc(e))*h),p(t=(Lc*t+jc)%Hc)/Hc}();function l(){h(),f.call("tick",n),e1?(null==e?u.delete(t):u.set(t,p(e)),n):u.get(t)},find:function(n,e,r){var i,o,a,u,c,f=0,s=t.length;for(null==r?r=1/0:r*=r,f=0;f1?(f.on(t,e),n):f.on(t)}}},t.forceX=function(t){var n,e,r,i=qc(.1);function o(t){for(var i,o=0,a=n.length;o=.12&&i<.234&&r>=-.425&&r<-.214?u:i>=.166&&i<.234&&r>=-.214&&r<-.115?c:a).invert(t)},s.stream=function(e){return t&&n===e?t:(r=[a.stream(n=e),u.stream(e),c.stream(e)],i=r.length,t={point:function(t,n){for(var e=-1;++ejs(r[0],r[1])&&(r[1]=i[1]),js(i[0],r[1])>js(r[0],r[1])&&(r[0]=i[0])):o.push(r=i);for(a=-1/0,n=0,r=o[e=o.length-1];n<=e;r=i,++n)i=o[n],(u=js(r[1],i[0]))>a&&(a=u,Wf=i[0],Kf=r[1])}return is=os=null,Wf===1/0||Zf===1/0?[[NaN,NaN],[NaN,NaN]]:[[Wf,Zf],[Kf,Qf]]},t.geoCentroid=function(t){ms=xs=ws=Ms=Ts=As=Ss=Es=0,Ns=new T,ks=new T,Cs=new T,Lf(t,Gs);var n=+Ns,e=+ks,r=+Cs,i=Ef(n,e,r);return i=0))throw new RangeError(`invalid digits: ${t}`);i=n}return null===n&&(r=new ed(i)),a},a.projection(t).digits(i).context(n)},t.geoProjection=yd,t.geoProjectionMutator=vd,t.geoRotation=ll,t.geoStereographic=function(){return yd(Bd).scale(250).clipAngle(142)},t.geoStereographicRaw=Bd,t.geoStream=Lf,t.geoTransform=function(t){return{stream:id(t)}},t.geoTransverseMercator=function(){var t=Ed(Yd),n=t.center,e=t.rotate;return t.center=function(t){return arguments.length?n([-t[1],t[0]]):[(t=n())[1],-t[0]]},t.rotate=function(t){return arguments.length?e([t[0],t[1],t.length>2?t[2]+90:90]):[(t=e())[0],t[1],t[2]-90]},e([0,0,90]).scale(159.155)},t.geoTransverseMercatorRaw=Yd,t.gray=function(t,n){return new ur(t,0,0,null==n?1:n)},t.greatest=ot,t.greatestIndex=function(t,e=n){if(1===e.length)return tt(t,e);let r,i=-1,o=-1;for(const n of t)++o,(i<0?0===e(n,n):e(n,r)>0)&&(r=n,i=o);return i},t.group=C,t.groupSort=function(t,e,r){return(2!==e.length?U($(t,e,r),(([t,e],[r,i])=>n(e,i)||n(t,r))):U(C(t,r),(([t,r],[i,o])=>e(r,o)||n(t,i)))).map((([t])=>t))},t.groups=P,t.hcl=dr,t.hierarchy=Gd,t.histogram=Q,t.hsl=He,t.html=Ec,t.image=function(t,n){return new Promise((function(e,r){var i=new Image;for(var o in n)i[o]=n[o];i.onerror=r,i.onload=function(){e(i)},i.src=t}))},t.index=function(t,...n){return F(t,k,R,n)},t.indexes=function(t,...n){return F(t,Array.from,R,n)},t.interpolate=Gr,t.interpolateArray=function(t,n){return(Ir(n)?Ur:Or)(t,n)},t.interpolateBasis=Er,t.interpolateBasisClosed=Nr,t.interpolateBlues=Gb,t.interpolateBrBG=ob,t.interpolateBuGn=Mb,t.interpolateBuPu=Ab,t.interpolateCividis=function(t){return t=Math.max(0,Math.min(1,t)),"rgb("+Math.max(0,Math.min(255,Math.round(-4.54-t*(35.34-t*(2381.73-t*(6402.7-t*(7024.72-2710.57*t)))))))+", "+Math.max(0,Math.min(255,Math.round(32.49+t*(170.73+t*(52.82-t*(131.46-t*(176.58-67.37*t)))))))+", "+Math.max(0,Math.min(255,Math.round(81.24+t*(442.36-t*(2482.43-t*(6167.24-t*(6614.94-2475.67*t)))))))+")"},t.interpolateCool=am,t.interpolateCubehelix=li,t.interpolateCubehelixDefault=im,t.interpolateCubehelixLong=hi,t.interpolateDate=Br,t.interpolateDiscrete=function(t){var n=t.length;return function(e){return t[Math.max(0,Math.min(n-1,Math.floor(e*n)))]}},t.interpolateGnBu=Eb,t.interpolateGreens=Wb,t.interpolateGreys=Kb,t.interpolateHcl=ci,t.interpolateHclLong=fi,t.interpolateHsl=oi,t.interpolateHslLong=ai,t.interpolateHue=function(t,n){var e=Pr(+t,+n);return function(t){var n=e(t);return n-360*Math.floor(n/360)}},t.interpolateInferno=pm,t.interpolateLab=function(t,n){var e=$r((t=ar(t)).l,(n=ar(n)).l),r=$r(t.a,n.a),i=$r(t.b,n.b),o=$r(t.opacity,n.opacity);return function(n){return t.l=e(n),t.a=r(n),t.b=i(n),t.opacity=o(n),t+""}},t.interpolateMagma=dm,t.interpolateNumber=Yr,t.interpolateNumberArray=Ur,t.interpolateObject=Lr,t.interpolateOrRd=kb,t.interpolateOranges=rm,t.interpolatePRGn=ub,t.interpolatePiYG=fb,t.interpolatePlasma=gm,t.interpolatePuBu=$b,t.interpolatePuBuGn=Pb,t.interpolatePuOr=lb,t.interpolatePuRd=Rb,t.interpolatePurples=Jb,t.interpolateRainbow=function(t){(t<0||t>1)&&(t-=Math.floor(t));var n=Math.abs(t-.5);return um.h=360*t-100,um.s=1.5-1.5*n,um.l=.8-.9*n,um+""},t.interpolateRdBu=db,t.interpolateRdGy=gb,t.interpolateRdPu=qb,t.interpolateRdYlBu=vb,t.interpolateRdYlGn=bb,t.interpolateReds=nm,t.interpolateRgb=Dr,t.interpolateRgbBasis=Fr,t.interpolateRgbBasisClosed=qr,t.interpolateRound=Vr,t.interpolateSinebow=function(t){var n;return t=(.5-t)*Math.PI,cm.r=255*(n=Math.sin(t))*n,cm.g=255*(n=Math.sin(t+fm))*n,cm.b=255*(n=Math.sin(t+sm))*n,cm+""},t.interpolateSpectral=xb,t.interpolateString=Xr,t.interpolateTransformCss=ti,t.interpolateTransformSvg=ni,t.interpolateTurbo=function(t){return t=Math.max(0,Math.min(1,t)),"rgb("+Math.max(0,Math.min(255,Math.round(34.61+t*(1172.33-t*(10793.56-t*(33300.12-t*(38394.49-14825.05*t)))))))+", "+Math.max(0,Math.min(255,Math.round(23.31+t*(557.33+t*(1225.33-t*(3574.96-t*(1073.77+707.56*t)))))))+", "+Math.max(0,Math.min(255,Math.round(27.2+t*(3211.1-t*(15327.97-t*(27814-t*(22569.18-6838.66*t)))))))+")"},t.interpolateViridis=hm,t.interpolateWarm=om,t.interpolateYlGn=Bb,t.interpolateYlGnBu=Ib,t.interpolateYlOrBr=Lb,t.interpolateYlOrRd=Hb,t.interpolateZoom=ri,t.interrupt=Gi,t.intersection=function(t,...n){t=new InternSet(t),n=n.map(vt);t:for(const e of t)for(const r of n)if(!r.has(e)){t.delete(e);continue t}return t},t.interval=function(t,n,e){var r=new Ei,i=n;return null==n?(r.restart(t,n,e),r):(r._restart=r.restart,r.restart=function(t,n,e){n=+n,e=null==e?Ai():+e,r._restart((function o(a){a+=i,r._restart(o,i+=n,e),t(a)}),n,e)},r.restart(t,n,e),r)},t.isoFormat=D_,t.isoParse=F_,t.json=function(t,n){return fetch(t,n).then(Tc)},t.lab=ar,t.lch=function(t,n,e,r){return 1===arguments.length?hr(t):new pr(e,n,t,null==r?1:r)},t.least=function(t,e=n){let r,i=!1;if(1===e.length){let o;for(const a of t){const t=e(a);(i?n(t,o)<0:0===n(t,t))&&(r=a,o=t,i=!0)}}else for(const n of t)(i?e(n,r)<0:0===e(n,n))&&(r=n,i=!0);return r},t.leastIndex=ht,t.line=Ym,t.lineRadial=Zm,t.link=ax,t.linkHorizontal=function(){return ax(nx)},t.linkRadial=function(){const t=ax(rx);return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t},t.linkVertical=function(){return ax(ex)},t.local=Qn,t.map=function(t,n){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");if("function"!=typeof n)throw new TypeError("mapper is not a function");return Array.from(t,((e,r)=>n(e,r,t)))},t.matcher=Vt,t.max=J,t.maxIndex=tt,t.mean=function(t,n){let e=0,r=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(++e,r+=n);else{let i=-1;for(let o of t)null!=(o=n(o,++i,t))&&(o=+o)>=o&&(++e,r+=o)}if(e)return r/e},t.median=function(t,n){return at(t,.5,n)},t.medianIndex=function(t,n){return ct(t,.5,n)},t.merge=ft,t.min=nt,t.minIndex=et,t.mode=function(t,n){const e=new InternMap;if(void 0===n)for(let n of t)null!=n&&n>=n&&e.set(n,(e.get(n)||0)+1);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&i>=i&&e.set(i,(e.get(i)||0)+1)}let r,i=0;for(const[t,n]of e)n>i&&(i=n,r=t);return r},t.namespace=It,t.namespaces=Ut,t.nice=Z,t.now=Ai,t.pack=function(){var t=null,n=1,e=1,r=np;function i(i){const o=ap();return i.x=n/2,i.y=e/2,t?i.eachBefore(xp(t)).eachAfter(wp(r,.5,o)).eachBefore(Mp(1)):i.eachBefore(xp(mp)).eachAfter(wp(np,1,o)).eachAfter(wp(r,i.r/Math.min(n,e),o)).eachBefore(Mp(Math.min(n,e)/(2*i.r))),i}return i.radius=function(n){return arguments.length?(t=Jd(n),i):t},i.size=function(t){return arguments.length?(n=+t[0],e=+t[1],i):[n,e]},i.padding=function(t){return arguments.length?(r="function"==typeof t?t:ep(+t),i):r},i},t.packEnclose=function(t){return up(t,ap())},t.packSiblings=function(t){return bp(t,ap()),t},t.pairs=function(t,n=st){const e=[];let r,i=!1;for(const o of t)i&&e.push(n(r,o)),r=o,i=!0;return e},t.partition=function(){var t=1,n=1,e=0,r=!1;function i(i){var o=i.height+1;return i.x0=i.y0=e,i.x1=t,i.y1=n/o,i.eachBefore(function(t,n){return function(r){r.children&&Ap(r,r.x0,t*(r.depth+1)/n,r.x1,t*(r.depth+2)/n);var i=r.x0,o=r.y0,a=r.x1-e,u=r.y1-e;a0&&(d+=l);for(null!=n?p.sort((function(t,e){return n(g[t],g[e])})):null!=e&&p.sort((function(t,n){return e(a[t],a[n])})),u=0,f=d?(v-h*b)/d:0;u0?l*f:0)+b,g[c]={data:a[c],index:u,value:l,startAngle:y,endAngle:s,padAngle:_};return g}return a.value=function(n){return arguments.length?(t="function"==typeof n?n:ym(+n),a):t},a.sortValues=function(t){return arguments.length?(n=t,e=null,a):n},a.sort=function(t){return arguments.length?(e=t,n=null,a):e},a.startAngle=function(t){return arguments.length?(r="function"==typeof t?t:ym(+t),a):r},a.endAngle=function(t){return arguments.length?(i="function"==typeof t?t:ym(+t),a):i},a.padAngle=function(t){return arguments.length?(o="function"==typeof t?t:ym(+t),a):o},a},t.piecewise=di,t.pointRadial=Qm,t.pointer=ne,t.pointers=function(t,n){return t.target&&(t=te(t),void 0===n&&(n=t.currentTarget),t=t.touches||[t]),Array.from(t,(t=>ne(t,n)))},t.polygonArea=function(t){for(var n,e=-1,r=t.length,i=t[r-1],o=0;++eu!=f>u&&a<(c-e)*(u-r)/(f-r)+e&&(s=!s),c=e,f=r;return s},t.polygonHull=function(t){if((e=t.length)<3)return null;var n,e,r=new Array(e),i=new Array(e);for(n=0;n=0;--n)f.push(t[r[o[n]][2]]);for(n=+u;n(n=1664525*n+1013904223|0,lg*(n>>>0))},t.randomLogNormal=Kp,t.randomLogistic=fg,t.randomNormal=Zp,t.randomPareto=ng,t.randomPoisson=sg,t.randomUniform=Vp,t.randomWeibull=ug,t.range=lt,t.rank=function(t,e=n){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");let r=Array.from(t);const i=new Float64Array(r.length);2!==e.length&&(r=r.map(e),e=n);const o=(t,n)=>e(r[t],r[n]);let a,u;return(t=Uint32Array.from(r,((t,n)=>n))).sort(e===n?(t,n)=>O(r[t],r[n]):I(o)),t.forEach(((t,n)=>{const e=o(t,void 0===a?t:a);e>=0?((void 0===a||e>0)&&(a=t,u=n),i[t]=u):i[t]=NaN})),i},t.reduce=function(t,n,e){if("function"!=typeof n)throw new TypeError("reducer is not a function");const r=t[Symbol.iterator]();let i,o,a=-1;if(arguments.length<3){if(({done:i,value:e}=r.next()),i)return;++a}for(;({done:i,value:o}=r.next()),!i;)e=n(e,o,++a,t);return e},t.reverse=function(t){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");return Array.from(t).reverse()},t.rgb=Fe,t.ribbon=function(){return Wa()},t.ribbonArrow=function(){return Wa(Va)},t.rollup=$,t.rollups=D,t.scaleBand=yg,t.scaleDiverging=function t(){var n=Ng(L_()(mg));return n.copy=function(){return B_(n,t())},dg.apply(n,arguments)},t.scaleDivergingLog=function t(){var n=Fg(L_()).domain([.1,1,10]);return n.copy=function(){return B_(n,t()).base(n.base())},dg.apply(n,arguments)},t.scaleDivergingPow=j_,t.scaleDivergingSqrt=function(){return j_.apply(null,arguments).exponent(.5)},t.scaleDivergingSymlog=function t(){var n=Ig(L_());return n.copy=function(){return B_(n,t()).constant(n.constant())},dg.apply(n,arguments)},t.scaleIdentity=function t(n){var e;function r(t){return null==t||isNaN(t=+t)?e:t}return r.invert=r,r.domain=r.range=function(t){return arguments.length?(n=Array.from(t,_g),r):n.slice()},r.unknown=function(t){return arguments.length?(e=t,r):e},r.copy=function(){return t(n).unknown(e)},n=arguments.length?Array.from(n,_g):[0,1],Ng(r)},t.scaleImplicit=pg,t.scaleLinear=function t(){var n=Sg();return n.copy=function(){return Tg(n,t())},hg.apply(n,arguments),Ng(n)},t.scaleLog=function t(){const n=Fg(Ag()).domain([1,10]);return n.copy=()=>Tg(n,t()).base(n.base()),hg.apply(n,arguments),n},t.scaleOrdinal=gg,t.scalePoint=function(){return vg(yg.apply(null,arguments).paddingInner(1))},t.scalePow=jg,t.scaleQuantile=function t(){var e,r=[],i=[],o=[];function a(){var t=0,n=Math.max(1,i.length);for(o=new Array(n-1);++t0?o[n-1]:r[0],n=i?[o[i-1],r]:[o[n-1],o[n]]},u.unknown=function(t){return arguments.length?(n=t,u):u},u.thresholds=function(){return o.slice()},u.copy=function(){return t().domain([e,r]).range(a).unknown(n)},hg.apply(Ng(u),arguments)},t.scaleRadial=function t(){var n,e=Sg(),r=[0,1],i=!1;function o(t){var r=function(t){return Math.sign(t)*Math.sqrt(Math.abs(t))}(e(t));return isNaN(r)?n:i?Math.round(r):r}return o.invert=function(t){return e.invert(Hg(t))},o.domain=function(t){return arguments.length?(e.domain(t),o):e.domain()},o.range=function(t){return arguments.length?(e.range((r=Array.from(t,_g)).map(Hg)),o):r.slice()},o.rangeRound=function(t){return o.range(t).round(!0)},o.round=function(t){return arguments.length?(i=!!t,o):i},o.clamp=function(t){return arguments.length?(e.clamp(t),o):e.clamp()},o.unknown=function(t){return arguments.length?(n=t,o):n},o.copy=function(){return t(e.domain(),r).round(i).clamp(e.clamp()).unknown(n)},hg.apply(o,arguments),Ng(o)},t.scaleSequential=function t(){var n=Ng(O_()(mg));return n.copy=function(){return B_(n,t())},dg.apply(n,arguments)},t.scaleSequentialLog=function t(){var n=Fg(O_()).domain([1,10]);return n.copy=function(){return B_(n,t()).base(n.base())},dg.apply(n,arguments)},t.scaleSequentialPow=Y_,t.scaleSequentialQuantile=function t(){var e=[],r=mg;function i(t){if(null!=t&&!isNaN(t=+t))return r((s(e,t,1)-1)/(e.length-1))}return i.domain=function(t){if(!arguments.length)return e.slice();e=[];for(let n of t)null==n||isNaN(n=+n)||e.push(n);return e.sort(n),i},i.interpolator=function(t){return arguments.length?(r=t,i):r},i.range=function(){return e.map(((t,n)=>r(n/(e.length-1))))},i.quantiles=function(t){return Array.from({length:t+1},((n,r)=>at(e,r/t)))},i.copy=function(){return t(r).domain(e)},dg.apply(i,arguments)},t.scaleSequentialSqrt=function(){return Y_.apply(null,arguments).exponent(.5)},t.scaleSequentialSymlog=function t(){var n=Ig(O_());return n.copy=function(){return B_(n,t()).constant(n.constant())},dg.apply(n,arguments)},t.scaleSqrt=function(){return jg.apply(null,arguments).exponent(.5)},t.scaleSymlog=function t(){var n=Ig(Ag());return n.copy=function(){return Tg(n,t()).constant(n.constant())},hg.apply(n,arguments)},t.scaleThreshold=function t(){var n,e=[.5],r=[0,1],i=1;function o(t){return null!=t&&t<=t?r[s(e,t,0,i)]:n}return o.domain=function(t){return arguments.length?(e=Array.from(t),i=Math.min(e.length,r.length-1),o):e.slice()},o.range=function(t){return arguments.length?(r=Array.from(t),i=Math.min(e.length,r.length-1),o):r.slice()},o.invertExtent=function(t){var n=r.indexOf(t);return[e[n-1],e[n]]},o.unknown=function(t){return arguments.length?(n=t,o):n},o.copy=function(){return t().domain(e).range(r).unknown(n)},hg.apply(o,arguments)},t.scaleTime=function(){return hg.apply(I_(uv,cv,tv,Zy,xy,py,sy,ay,iy,t.timeFormat).domain([new Date(2e3,0,1),new Date(2e3,0,2)]),arguments)},t.scaleUtc=function(){return hg.apply(I_(ov,av,ev,Qy,Fy,yy,hy,cy,iy,t.utcFormat).domain([Date.UTC(2e3,0,1),Date.UTC(2e3,0,2)]),arguments)},t.scan=function(t,n){const e=ht(t,n);return e<0?void 0:e},t.schemeAccent=G_,t.schemeBlues=Xb,t.schemeBrBG=ib,t.schemeBuGn=wb,t.schemeBuPu=Tb,t.schemeCategory10=X_,t.schemeDark2=V_,t.schemeGnBu=Sb,t.schemeGreens=Vb,t.schemeGreys=Zb,t.schemeObservable10=W_,t.schemeOrRd=Nb,t.schemeOranges=em,t.schemePRGn=ab,t.schemePaired=Z_,t.schemePastel1=K_,t.schemePastel2=Q_,t.schemePiYG=cb,t.schemePuBu=zb,t.schemePuBuGn=Cb,t.schemePuOr=sb,t.schemePuRd=Db,t.schemePurples=Qb,t.schemeRdBu=hb,t.schemeRdGy=pb,t.schemeRdPu=Fb,t.schemeRdYlBu=yb,t.schemeRdYlGn=_b,t.schemeReds=tm,t.schemeSet1=J_,t.schemeSet2=tb,t.schemeSet3=nb,t.schemeSpectral=mb,t.schemeTableau10=eb,t.schemeYlGn=Ob,t.schemeYlGnBu=Ub,t.schemeYlOrBr=Yb,t.schemeYlOrRd=jb,t.select=Zn,t.selectAll=function(t){return"string"==typeof t?new Vn([document.querySelectorAll(t)],[document.documentElement]):new Vn([Ht(t)],Gn)},t.selection=Wn,t.selector=jt,t.selectorAll=Gt,t.shuffle=dt,t.shuffler=pt,t.some=function(t,n){if("function"!=typeof n)throw new TypeError("test is not a function");let e=-1;for(const r of t)if(n(r,++e,t))return!0;return!1},t.sort=U,t.stack=function(){var t=ym([]),n=dw,e=hw,r=pw;function i(i){var o,a,u=Array.from(t.apply(this,arguments),gw),c=u.length,f=-1;for(const t of i)for(o=0,++f;o0)for(var e,r,i,o,a,u,c=0,f=t[n[0]].length;c0?(r[0]=o,r[1]=o+=i):i<0?(r[1]=a,r[0]=a+=i):(r[0]=0,r[1]=i)},t.stackOffsetExpand=function(t,n){if((r=t.length)>0){for(var e,r,i,o=0,a=t[0].length;o0){for(var e,r=0,i=t[n[0]],o=i.length;r0&&(r=(e=t[n[0]]).length)>0){for(var e,r,i,o=0,a=1;afunction(t){t=`${t}`;let n=t.length;zp(t,n-1)&&!zp(t,n-2)&&(t=t.slice(0,-1));return"/"===t[0]?t:`/${t}`}(t(n,e,r)))),e=n.map(Pp),i=new Set(n).add("");for(const t of e)i.has(t)||(i.add(t),n.push(t),e.push(Pp(t)),h.push(Np));d=(t,e)=>n[e],p=(t,n)=>e[n]}for(a=0,i=h.length;a=0&&(f=h[t]).data===Np;--t)f.data=null}if(u.parent=Sp,u.eachBefore((function(t){t.depth=t.parent.depth+1,--i})).eachBefore(Kd),u.parent=null,i>0)throw new Error("cycle");return u}return r.id=function(t){return arguments.length?(n=Jd(t),r):n},r.parentId=function(t){return arguments.length?(e=Jd(t),r):e},r.path=function(n){return arguments.length?(t=Jd(n),r):t},r},t.style=_n,t.subset=function(t,n){return _t(n,t)},t.sum=function(t,n){let e=0;if(void 0===n)for(let n of t)(n=+n)&&(e+=n);else{let r=-1;for(let i of t)(i=+n(i,++r,t))&&(e+=i)}return e},t.superset=_t,t.svg=Nc,t.symbol=function(t,n){let e=null,r=km(i);function i(){let i;if(e||(e=i=r()),t.apply(this,arguments).draw(e,+n.apply(this,arguments)),i)return e=null,i+""||null}return t="function"==typeof t?t:ym(t||fx),n="function"==typeof n?n:ym(void 0===n?64:+n),i.type=function(n){return arguments.length?(t="function"==typeof n?n:ym(n),i):t},i.size=function(t){return arguments.length?(n="function"==typeof t?t:ym(+t),i):n},i.context=function(t){return arguments.length?(e=null==t?null:t,i):e},i},t.symbolAsterisk=cx,t.symbolCircle=fx,t.symbolCross=sx,t.symbolDiamond=dx,t.symbolDiamond2=px,t.symbolPlus=gx,t.symbolSquare=yx,t.symbolSquare2=vx,t.symbolStar=xx,t.symbolTimes=Px,t.symbolTriangle=Mx,t.symbolTriangle2=Ax,t.symbolWye=Cx,t.symbolX=Px,t.symbols=zx,t.symbolsFill=zx,t.symbolsStroke=$x,t.text=mc,t.thresholdFreedmanDiaconis=function(t,n,e){const r=v(t),i=at(t,.75)-at(t,.25);return r&&i?Math.ceil((e-n)/(2*i*Math.pow(r,-1/3))):1},t.thresholdScott=function(t,n,e){const r=v(t),i=w(t);return r&&i?Math.ceil((e-n)*Math.cbrt(r)/(3.49*i)):1},t.thresholdSturges=K,t.tickFormat=Eg,t.tickIncrement=V,t.tickStep=W,t.ticks=G,t.timeDay=py,t.timeDays=gy,t.timeFormatDefaultLocale=P_,t.timeFormatLocale=hv,t.timeFriday=Sy,t.timeFridays=$y,t.timeHour=sy,t.timeHours=ly,t.timeInterval=Vg,t.timeMillisecond=Wg,t.timeMilliseconds=Zg,t.timeMinute=ay,t.timeMinutes=uy,t.timeMonday=wy,t.timeMondays=ky,t.timeMonth=Zy,t.timeMonths=Ky,t.timeSaturday=Ey,t.timeSaturdays=Dy,t.timeSecond=iy,t.timeSeconds=oy,t.timeSunday=xy,t.timeSundays=Ny,t.timeThursday=Ay,t.timeThursdays=zy,t.timeTickInterval=cv,t.timeTicks=uv,t.timeTuesday=My,t.timeTuesdays=Cy,t.timeWednesday=Ty,t.timeWednesdays=Py,t.timeWeek=xy,t.timeWeeks=Ny,t.timeYear=tv,t.timeYears=nv,t.timeout=$i,t.timer=Ni,t.timerFlush=ki,t.transition=go,t.transpose=gt,t.tree=function(){var t=$p,n=1,e=1,r=null;function i(i){var c=function(t){for(var n,e,r,i,o,a=new Up(t,0),u=[a];n=u.pop();)if(r=n._.children)for(n.children=new Array(o=r.length),i=o-1;i>=0;--i)u.push(e=n.children[i]=new Up(r[i],i)),e.parent=n;return(a.parent=new Up(null,0)).children=[a],a}(i);if(c.eachAfter(o),c.parent.m=-c.z,c.eachBefore(a),r)i.eachBefore(u);else{var f=i,s=i,l=i;i.eachBefore((function(t){t.xs.x&&(s=t),t.depth>l.depth&&(l=t)}));var h=f===s?1:t(f,s)/2,d=h-f.x,p=n/(s.x+h+d),g=e/(l.depth||1);i.eachBefore((function(t){t.x=(t.x+d)*p,t.y=t.depth*g}))}return i}function o(n){var e=n.children,r=n.parent.children,i=n.i?r[n.i-1]:null;if(e){!function(t){for(var n,e=0,r=0,i=t.children,o=i.length;--o>=0;)(n=i[o]).z+=e,n.m+=e,e+=n.s+(r+=n.c)}(n);var o=(e[0].z+e[e.length-1].z)/2;i?(n.z=i.z+t(n._,i._),n.m=n.z-o):n.z=o}else i&&(n.z=i.z+t(n._,i._));n.parent.A=function(n,e,r){if(e){for(var i,o=n,a=n,u=e,c=o.parent.children[0],f=o.m,s=a.m,l=u.m,h=c.m;u=Rp(u),o=Dp(o),u&&o;)c=Dp(c),(a=Rp(a)).a=n,(i=u.z+l-o.z-f+t(u._,o._))>0&&(Fp(qp(u,n,r),n,i),f+=i,s+=i),l+=u.m,f+=o.m,h+=c.m,s+=a.m;u&&!Rp(a)&&(a.t=u,a.m+=l-s),o&&!Dp(c)&&(c.t=o,c.m+=f-h,r=n)}return r}(n,i,n.parent.A||r[0])}function a(t){t._.x=t.z+t.parent.m,t.m+=t.parent.m}function u(t){t.x*=n,t.y=t.depth*e}return i.separation=function(n){return arguments.length?(t=n,i):t},i.size=function(t){return arguments.length?(r=!1,n=+t[0],e=+t[1],i):r?null:[n,e]},i.nodeSize=function(t){return arguments.length?(r=!0,n=+t[0],e=+t[1],i):r?[n,e]:null},i},t.treemap=function(){var t=Yp,n=!1,e=1,r=1,i=[0],o=np,a=np,u=np,c=np,f=np;function s(t){return t.x0=t.y0=0,t.x1=e,t.y1=r,t.eachBefore(l),i=[0],n&&t.eachBefore(Tp),t}function l(n){var e=i[n.depth],r=n.x0+e,s=n.y0+e,l=n.x1-e,h=n.y1-e;l=e-1){var s=u[n];return s.x0=i,s.y0=o,s.x1=a,void(s.y1=c)}var l=f[n],h=r/2+l,d=n+1,p=e-1;for(;d>>1;f[g]c-o){var _=r?(i*v+a*y)/r:a;t(n,d,y,i,o,_,c),t(d,e,v,_,o,a,c)}else{var b=r?(o*v+c*y)/r:c;t(n,d,y,i,o,a,b),t(d,e,v,i,b,a,c)}}(0,c,t.value,n,e,r,i)},t.treemapDice=Ap,t.treemapResquarify=Lp,t.treemapSlice=Ip,t.treemapSliceDice=function(t,n,e,r,i){(1&t.depth?Ip:Ap)(t,n,e,r,i)},t.treemapSquarify=Yp,t.tsv=Mc,t.tsvFormat=lc,t.tsvFormatBody=hc,t.tsvFormatRow=pc,t.tsvFormatRows=dc,t.tsvFormatValue=gc,t.tsvParse=fc,t.tsvParseRows=sc,t.union=function(...t){const n=new InternSet;for(const e of t)for(const t of e)n.add(t);return n},t.unixDay=_y,t.unixDays=by,t.utcDay=yy,t.utcDays=vy,t.utcFriday=By,t.utcFridays=Vy,t.utcHour=hy,t.utcHours=dy,t.utcMillisecond=Wg,t.utcMilliseconds=Zg,t.utcMinute=cy,t.utcMinutes=fy,t.utcMonday=qy,t.utcMondays=jy,t.utcMonth=Qy,t.utcMonths=Jy,t.utcSaturday=Yy,t.utcSaturdays=Wy,t.utcSecond=iy,t.utcSeconds=oy,t.utcSunday=Fy,t.utcSundays=Ly,t.utcThursday=Oy,t.utcThursdays=Gy,t.utcTickInterval=av,t.utcTicks=ov,t.utcTuesday=Uy,t.utcTuesdays=Hy,t.utcWednesday=Iy,t.utcWednesdays=Xy,t.utcWeek=Fy,t.utcWeeks=Ly,t.utcYear=ev,t.utcYears=rv,t.variance=x,t.version="7.9.0",t.window=pn,t.xml=Sc,t.zip=function(){return gt(arguments)},t.zoom=function(){var t,n,e,r=Ew,i=Nw,o=zw,a=Cw,u=Pw,c=[0,1/0],f=[[-1/0,-1/0],[1/0,1/0]],s=250,l=ri,h=$t("start","zoom","end"),d=500,p=150,g=0,y=10;function v(t){t.property("__zoom",kw).on("wheel.zoom",T,{passive:!1}).on("mousedown.zoom",A).on("dblclick.zoom",S).filter(u).on("touchstart.zoom",E).on("touchmove.zoom",N).on("touchend.zoom touchcancel.zoom",k).style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function _(t,n){return(n=Math.max(c[0],Math.min(c[1],n)))===t.k?t:new ww(n,t.x,t.y)}function b(t,n,e){var r=n[0]-e[0]*t.k,i=n[1]-e[1]*t.k;return r===t.x&&i===t.y?t:new ww(t.k,r,i)}function m(t){return[(+t[0][0]+ +t[1][0])/2,(+t[0][1]+ +t[1][1])/2]}function x(t,n,e,r){t.on("start.zoom",(function(){w(this,arguments).event(r).start()})).on("interrupt.zoom end.zoom",(function(){w(this,arguments).event(r).end()})).tween("zoom",(function(){var t=this,o=arguments,a=w(t,o).event(r),u=i.apply(t,o),c=null==e?m(u):"function"==typeof e?e.apply(t,o):e,f=Math.max(u[1][0]-u[0][0],u[1][1]-u[0][1]),s=t.__zoom,h="function"==typeof n?n.apply(t,o):n,d=l(s.invert(c).concat(f/s.k),h.invert(c).concat(f/h.k));return function(t){if(1===t)t=h;else{var n=d(t),e=f/n[2];t=new ww(e,c[0]-n[0]*e,c[1]-n[1]*e)}a.zoom(null,t)}}))}function w(t,n,e){return!e&&t.__zooming||new M(t,n)}function M(t,n){this.that=t,this.args=n,this.active=0,this.sourceEvent=null,this.extent=i.apply(t,n),this.taps=0}function T(t,...n){if(r.apply(this,arguments)){var e=w(this,n).event(t),i=this.__zoom,u=Math.max(c[0],Math.min(c[1],i.k*Math.pow(2,a.apply(this,arguments)))),s=ne(t);if(e.wheel)e.mouse[0][0]===s[0]&&e.mouse[0][1]===s[1]||(e.mouse[1]=i.invert(e.mouse[0]=s)),clearTimeout(e.wheel);else{if(i.k===u)return;e.mouse=[s,i.invert(s)],Gi(this),e.start()}Sw(t),e.wheel=setTimeout((function(){e.wheel=null,e.end()}),p),e.zoom("mouse",o(b(_(i,u),e.mouse[0],e.mouse[1]),e.extent,f))}}function A(t,...n){if(!e&&r.apply(this,arguments)){var i=t.currentTarget,a=w(this,n,!0).event(t),u=Zn(t.view).on("mousemove.zoom",(function(t){if(Sw(t),!a.moved){var n=t.clientX-s,e=t.clientY-l;a.moved=n*n+e*e>g}a.event(t).zoom("mouse",o(b(a.that.__zoom,a.mouse[0]=ne(t,i),a.mouse[1]),a.extent,f))}),!0).on("mouseup.zoom",(function(t){u.on("mousemove.zoom mouseup.zoom",null),ue(t.view,a.moved),Sw(t),a.event(t).end()}),!0),c=ne(t,i),s=t.clientX,l=t.clientY;ae(t.view),Aw(t),a.mouse=[c,this.__zoom.invert(c)],Gi(this),a.start()}}function S(t,...n){if(r.apply(this,arguments)){var e=this.__zoom,a=ne(t.changedTouches?t.changedTouches[0]:t,this),u=e.invert(a),c=e.k*(t.shiftKey?.5:2),l=o(b(_(e,c),a,u),i.apply(this,n),f);Sw(t),s>0?Zn(this).transition().duration(s).call(x,l,a,t):Zn(this).call(v.transform,l,a,t)}}function E(e,...i){if(r.apply(this,arguments)){var o,a,u,c,f=e.touches,s=f.length,l=w(this,i,e.changedTouches.length===s).event(e);for(Aw(e),a=0;a + + + diff --git a/harnesses/cursor/extension/package-lock.json b/harnesses/cursor/extension/package-lock.json new file mode 100644 index 00000000..4c7c73be --- /dev/null +++ b/harnesses/cursor/extension/package-lock.json @@ -0,0 +1,1693 @@ +{ + "name": "hivemind-cursor-extension", + "version": "0.1.5", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hivemind-cursor-extension", + "version": "0.1.5", + "devDependencies": { + "@types/node": "^20.11.0", + "@types/vscode": "^1.85.0", + "ts-loader": "^9.5.1", + "typescript": "^5.4.0", + "webpack": "^5.90.0", + "webpack-cli": "^5.1.4" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.120.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", + "integrity": "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz", + "integrity": "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-loader": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "loader-utils": "*", + "typescript": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.107.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", + "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.0", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.5.0", + "watchpack": "^2.5.1", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/harnesses/cursor/extension/package.json b/harnesses/cursor/extension/package.json new file mode 100644 index 00000000..abb14f81 --- /dev/null +++ b/harnesses/cursor/extension/package.json @@ -0,0 +1,85 @@ +{ + "name": "hivemind-cursor-extension", + "displayName": "Hivemind for Cursor", + "description": "Shared memory, health checks, dashboard, and codebase graph inside Cursor", + "version": "0.1.5", + "publisher": "deeplake", + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onStartupFinished" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { + "command": "hivemind.runOnboarding", + "title": "Hivemind: Run Onboarding" + }, + { + "command": "hivemind.login", + "title": "Hivemind: Log In" + }, + { + "command": "hivemind.logout", + "title": "Hivemind: Log Out" + }, + { + "command": "hivemind.showStatus", + "title": "Hivemind: Show Status" + }, + { + "command": "hivemind.wireHooks", + "title": "Hivemind: Wire / Refresh Hooks" + }, + { + "command": "hivemind.unwireHooks", + "title": "Hivemind: Unwire Hooks" + }, + { + "command": "hivemind.openLogs", + "title": "Hivemind: Open Logs" + }, + { + "command": "hivemind.openDashboard", + "title": "Hivemind: Open Dashboard" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "hivemind", + "title": "Hivemind", + "icon": "media/icon.svg" + } + ] + }, + "views": { + "hivemind": [ + { + "id": "hivemind.dashboard", + "name": "Dashboard", + "type": "webview" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "webpack --mode production", + "watch": "webpack --mode development --watch", + "lint": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "@types/vscode": "^1.85.0", + "ts-loader": "^9.5.1", + "typescript": "^5.4.0", + "webpack": "^5.90.0", + "webpack-cli": "^5.1.4" + } +} diff --git a/harnesses/cursor/extension/scripts/lib/deeplake.mjs b/harnesses/cursor/extension/scripts/lib/deeplake.mjs new file mode 100644 index 00000000..8ed685ff --- /dev/null +++ b/harnesses/cursor/extension/scripts/lib/deeplake.mjs @@ -0,0 +1,140 @@ +/** + * Self-contained Deeplake data helper for the Cursor extension loaders. + * + * The loader scripts run via plain `node` spawned from the packaged + * extension. The core Hivemind CLI source (repo-root `src/`) is neither + * shipped in the vsix nor compiled to `.js`, so the old loaders that did + * `import ... from "../../../../src/*.js"` always failed at runtime. This + * module reimplements just the small slice of behaviour the loaders need, + * with no dependency outside the scripts directory: reading credentials, + * running a SQL query against the Deeplake HTTP endpoint, escaping SQL + * literals, and deriving the per-repo graph key the core uses. + */ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; + +const DEFAULT_API_URL = "https://api.deeplake.ai"; + +export function loadCreds() { + try { + const raw = readFileSync(join(homedir(), ".deeplake", "credentials.json"), "utf-8"); + const creds = JSON.parse(raw); + if (!creds || !creds.token || !creds.orgId) return null; + return { + token: creds.token, + orgId: creds.orgId, + orgName: creds.orgName ?? creds.orgId, + userName: creds.userName ?? "", + workspaceId: creds.workspaceId ?? "default", + apiUrl: creds.apiUrl ?? DEFAULT_API_URL, + }; + } catch { + return null; + } +} + +/** Table names, matching core `src/config.ts` defaults plus env overrides. */ +export function tableNames() { + return { + memory: process.env.HIVEMIND_TABLE ?? "memory", + sessions: process.env.HIVEMIND_SESSIONS_TABLE ?? "sessions", + rules: process.env.HIVEMIND_RULES_TABLE ?? "hivemind_rules", + goals: process.env.HIVEMIND_GOALS_TABLE ?? "hivemind_goals", + }; +} + +/** Escape a string for a single-quoted SQL literal. Mirrors src/utils/sql.ts. */ +export function sqlStr(value) { + return String(value) + .replace(/\\/g, "\\\\") + .replace(/'/g, "''") + .replace(/\0/g, "") + // eslint-disable-next-line no-control-regex + .replace(/[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); +} + +/** Validate a SQL identifier (table/column). Mirrors src/utils/sql.ts. */ +export function sqlIdent(name) { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + throw new Error(`Invalid SQL identifier: ${JSON.stringify(name)}`); + } + return name; +} + +/** True when the API error means the table has not been created yet. */ +export function isMissingTableError(message) { + return /does not exist|no such table|not found/i.test(String(message ?? "")); +} + +/** + * Run a SQL query against the Deeplake query endpoint and return rows as + * plain objects. Mirrors the request shape in src/deeplake-api.ts. + */ +export async function query(creds, sql, timeoutMs = 8000) { + const resp = await fetch(`${creds.apiUrl}/workspaces/${creds.workspaceId}/tables/query`, { + method: "POST", + headers: { + Authorization: `Bearer ${creds.token}`, + "Content-Type": "application/json", + "X-Activeloop-Org-Id": creds.orgId, + "X-Deeplake-Client": "hivemind", + }, + signal: AbortSignal.timeout(timeoutMs), + body: JSON.stringify({ query: sql }), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`Query failed: ${resp.status}: ${text.slice(0, 200)}`); + } + const raw = await resp.json().catch(() => null); + if (!raw || !Array.isArray(raw.rows) || !Array.isArray(raw.columns)) return []; + return raw.rows.map((row) => Object.fromEntries(raw.columns.map((col, i) => [col, row[i]]))); +} + +/** Collapse the surface forms of a git remote URL. Mirrors src/utils/repo-identity.ts. */ +export function normalizeGitRemoteUrl(url) { + let s = String(url).trim(); + const schemeMatch = s.match(/^([a-z][a-z0-9+.-]*):\/\//i); + const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null; + if (schemeMatch) s = s.slice(schemeMatch[0].length); + if (!scheme) { + const scp = s.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/); + if (scp) s = `${scp[1]}/${scp[2]}`; + } + s = s.replace(/^[^@/]+@/, ""); + const defaultPorts = { http: "80", https: "443", ssh: "22", git: "9418" }; + if (scheme && defaultPorts[scheme]) { + s = s.replace(new RegExp(`^([^/]+):${defaultPorts[scheme]}(/|$)`), "$1$2"); + } + s = s.replace(/\.git\/?$/i, ""); + s = s.replace(/\/+$/, ""); + return s.toLowerCase(); +} + +/** + * Stable per-repo key: sha1 of the normalized git remote (fallback to the + * absolute cwd), first 16 hex chars. Mirrors core deriveProjectKey so the + * extension resolves the SAME `~/.hivemind/graphs/` dir the CLI writes. + */ +export function deriveProjectKey(cwd) { + let signature = null; + try { + const raw = execSync("git config --get remote.origin.url", { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + signature = raw ? normalizeGitRemoteUrl(raw) : null; + } catch { + signature = null; + } + const input = signature ?? cwd; + return createHash("sha1").update(input).digest("hex").slice(0, 16); +} + +export function graphsHome() { + return process.env.HIVEMIND_GRAPHS_HOME ?? join(homedir(), ".hivemind", "graphs"); +} diff --git a/harnesses/cursor/extension/scripts/load-dashboard.mjs b/harnesses/cursor/extension/scripts/load-dashboard.mjs new file mode 100644 index 00000000..d5cd31d5 --- /dev/null +++ b/harnesses/cursor/extension/scripts/load-dashboard.mjs @@ -0,0 +1,305 @@ +/** + * Build the dashboard data envelope (KPIs + codebase graph snapshot). + * + * Self-contained: resolves the per-repo graph key the same way the core CLI + * does (see lib/deeplake.mjs deriveProjectKey) so it finds the snapshot the + * `hivemind graph build` command actually wrote under + * ~/.hivemind/graphs//snapshots. KPIs come from the org-stats cache the + * CLI maintains, falling back to local usage records, then to an empty state. + * Prints a DashboardDataEnvelope JSON to stdout. + */ +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { loadCreds, deriveProjectKey, graphsHome, query, sqlStr, sqlIdent, tableNames } from "./lib/deeplake.mjs"; + +const cwd = process.argv[2] || process.cwd(); + +const BYTES_PER_TOKEN = 4; +const SAVINGS_MULTIPLIER = 1.7; + +function bytesToSavedTokens(bytes) { + if (!Number.isFinite(bytes) || bytes <= 0) return 0; + return (SAVINGS_MULTIPLIER - 1) * (bytes / BYTES_PER_TOKEN); +} + +function countUserGeneratedSkills(userName) { + if (!userName) return 0; + const dir = join(homedir(), ".claude", "skills"); + if (!existsSync(dir)) return 0; + const suffix = `--${userName}`; + try { + let count = 0; + for (const name of readdirSync(dir)) { + const idx = name.lastIndexOf(suffix); + if (idx > 0 && idx + suffix.length === name.length) count += 1; + } + return count; + } catch { + return 0; + } +} + +function readUsageRecords() { + const path = join(homedir(), ".deeplake", "usage-stats.jsonl"); + if (!existsSync(path)) return []; + const out = []; + try { + for (const line of readFileSync(path, "utf-8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const rec = JSON.parse(trimmed); + if (typeof rec.endedAt === "string" && typeof rec.sessionId === "string") { + out.push({ + memorySearchBytes: typeof rec.memorySearchBytes === "number" ? rec.memorySearchBytes : 0, + memorySearchCount: typeof rec.memorySearchCount === "number" ? rec.memorySearchCount : 0, + }); + } + } catch { + /* skip bad line */ + } + } + } catch { + return []; + } + return out; +} + +const STATS_CACHE_TTL_MS = 3600_000; + +function statsCachePath() { + return join(homedir(), ".deeplake", "hivemind-stats-cache.json"); +} + +function statsScopeKey(creds) { + return JSON.stringify({ + apiUrl: creds.apiUrl ?? "https://api.deeplake.ai", + orgId: creds.orgId ?? "", + userName: creds.userName ?? "", + }); +} + +function nonNegNumber(value) { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; +} + +function scopeFromServer(scope) { + return { + sessionsCount: nonNegNumber(scope?.sessions_count), + memoryRecallCount: nonNegNumber(scope?.memory_recall_count), + memorySearchBytes: nonNegNumber(scope?.memory_search_bytes), + }; +} + +function readStatsCache(scopeKey) { + try { + if (!existsSync(statsCachePath())) return {}; + const parsed = JSON.parse(readFileSync(statsCachePath(), "utf-8")); + if (!parsed || parsed.scopeKey !== scopeKey || typeof parsed.fetchedAt !== "number") return {}; + if (!parsed.data?.org) return {}; + const age = Date.now() - parsed.fetchedAt; + if (age >= 0 && age < STATS_CACHE_TTL_MS) return { fresh: parsed.data, fetchedAt: parsed.fetchedAt }; + return { stale: parsed.data, fetchedAt: parsed.fetchedAt }; + } catch { + return {}; + } +} + +function writeStatsCache(scopeKey, data) { + try { + mkdirSync(dirname(statsCachePath()), { recursive: true }); + writeFileSync(statsCachePath(), JSON.stringify({ fetchedAt: Date.now(), scopeKey, data }), "utf-8"); + } catch { + /* best-effort cache */ + } +} + +/** + * Resolve org/user activity stats the same way the core CLI does: fresh + * cache first, then a live `GET /me/hivemind-stats`, then stale cache. + * Returns null when no creds or the endpoint is unreachable with no cache. + */ +async function fetchOrgStats(creds) { + if (!creds?.token) return null; + const apiUrl = creds.apiUrl ?? "https://api.deeplake.ai"; + const scopeKey = statsScopeKey(creds); + const { fresh, stale, fetchedAt } = readStatsCache(scopeKey); + if (fresh) { + return { org: fresh.org, user: fresh.user ?? fresh.org, fetchedAt: new Date(fetchedAt).toISOString(), stale: false, offline: false }; + } + try { + const resp = await fetch(`${apiUrl}/me/hivemind-stats`, { + headers: { + Authorization: `Bearer ${creds.token}`, + ...(creds.orgId ? { "X-Activeloop-Org-Id": creds.orgId } : {}), + }, + signal: AbortSignal.timeout(8000), + }); + if (resp.ok) { + const body = await resp.json().catch(() => null); + if (body && typeof body === "object") { + const data = { org: scopeFromServer(body.org), user: scopeFromServer(body.user) }; + writeStatsCache(scopeKey, data); + return { org: data.org, user: data.user, fetchedAt: new Date().toISOString(), stale: false, offline: false }; + } + } + } catch { + /* fall through to stale */ + } + if (stale) { + return { org: stale.org, user: stale.user ?? stale.org, fetchedAt: new Date(fetchedAt).toISOString(), stale: true, offline: true }; + } + return null; +} + +/** Sum the locally-tracked recall events (count + bytes delivered). */ +function readRecallEvents() { + const path = join(homedir(), ".deeplake", "recall-events.jsonl"); + if (!existsSync(path)) return { count: 0, bytes: 0 }; + let count = 0; + let bytes = 0; + try { + for (const line of readFileSync(path, "utf-8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const rec = JSON.parse(trimmed); + if (typeof rec.bytes === "number" && rec.bytes > 0) { + count += 1; + bytes += rec.bytes; + } + } catch { + /* skip bad line */ + } + } + } catch { + return { count: 0, bytes: 0 }; + } + return { count, bytes }; +} + +/** Real distinct-session count for this user from the sessions table. */ +async function fetchDistinctSessions(creds) { + if (!creds || !creds.userName) return null; + try { + const table = sqlIdent(tableNames().sessions); + const rows = await query( + creds, + `SELECT COUNT(DISTINCT path) AS c FROM "${table}" WHERE author = '${sqlStr(creds.userName)}'`, + ); + const c = rows && rows[0] ? Number(rows[0].c) : NaN; + return Number.isFinite(c) ? c : null; + } catch { + return null; + } +} + +function resolveSnapshot(repoDir) { + const snapshotsDir = join(repoDir, "snapshots"); + if (!existsSync(snapshotsDir)) return null; + let snapshotPath = null; + const pointer = join(repoDir, "latest-commit.txt"); + if (existsSync(pointer)) { + try { + const sha = readFileSync(pointer, "utf-8").trim(); + const candidate = join(snapshotsDir, `${sha}.json`); + if (sha && existsSync(candidate)) snapshotPath = candidate; + } catch { + /* fall through to newest-file scan */ + } + } + if (!snapshotPath) { + try { + 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; + } catch { + return null; + } + } + if (!snapshotPath) return null; + try { + const parsed = JSON.parse(readFileSync(snapshotPath, "utf-8")); + 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; + } +} + +const creds = loadCreds(); +const repoKey = deriveProjectKey(cwd); +const repoProject = basename(cwd); +const graph = resolveSnapshot(join(graphsHome(), repoKey)); +const skillsCreated = countUserGeneratedSkills(creds?.userName); + +// Sessions: real distinct-session count for this user from the sessions +// table (consistent with the Sessions tab), with org rollup as a fallback. +const distinctSessions = await fetchDistinctSessions(creds); +// Memory recall: tracked locally by the pre-tool-use hook into +// recall-events.jsonl. The server rollup does not yet count Cursor recalls, +// so this local store is the source of truth for memory-search / tokens-saved. +const recall = readRecallEvents(); +const org = await fetchOrgStats(creds); + +let sessionsCount = distinctSessions; +if (sessionsCount == null && org) sessionsCount = org.org.sessionsCount ?? null; + +let kpis; +if (recall.count > 0) { + kpis = { + tokensSaved: bytesToSavedTokens(recall.bytes), + tokensSource: "local", + skillsCreated, + memorySearches: recall.count, + sessionsCount, + userTokensSaved: bytesToSavedTokens(recall.bytes), + orgStatsFetchedAt: null, + orgStatsStale: false, + orgStatsOffline: false, + }; +} else if (org && ((org.org.memoryRecallCount ?? 0) > 0 || (org.org.memorySearchBytes ?? 0) > 0)) { + kpis = { + tokensSaved: bytesToSavedTokens(org.org.memorySearchBytes ?? 0), + tokensSource: "org", + skillsCreated, + memorySearches: org.org.memoryRecallCount ?? 0, + sessionsCount, + userTokensSaved: bytesToSavedTokens((org.user ?? org.org).memorySearchBytes ?? 0), + orgStatsFetchedAt: org.fetchedAt, + orgStatsStale: org.stale, + orgStatsOffline: org.offline, + }; +} else { + // Sessions are real; memory-recall metrics have not accumulated yet. + kpis = { + tokensSaved: null, + tokensSource: "none", + skillsCreated, + memorySearches: 0, + sessionsCount, + userTokensSaved: null, + orgStatsFetchedAt: org ? org.fetchedAt : null, + orgStatsStale: org ? org.stale : false, + orgStatsOffline: org ? org.offline : false, + }; +} + +process.stdout.write( + JSON.stringify({ + repoKey, + repoProject, + generatedAt: new Date().toISOString(), + kpis, + graph, + }), +); diff --git a/harnesses/cursor/extension/scripts/load-goals.mjs b/harnesses/cursor/extension/scripts/load-goals.mjs new file mode 100644 index 00000000..8bfbf2de --- /dev/null +++ b/harnesses/cursor/extension/scripts/load-goals.mjs @@ -0,0 +1,62 @@ +/** + * List goals from the Deeplake `hivemind_goals` table. + * + * Self-contained: reads credentials and queries the Deeplake HTTP endpoint + * directly. Mirrors core src/commands/goal.ts goalList, with latest-version + * dedup per goal_id (the VFS write path appends a fresh row per overwrite). + * Prints a GoalsListResult JSON to stdout. + * + * argv[2]: filter — "mine" (default) or "all". + */ +import { loadCreds, query, sqlIdent, sqlStr, tableNames, isMissingTableError } from "./lib/deeplake.mjs"; + +const filter = process.argv[2] === "all" ? "all" : "mine"; + +function emit(obj) { + process.stdout.write(JSON.stringify(obj)); +} + +const creds = loadCreds(); +if (!creds) { + emit({ loggedOut: true, goals: [], message: "Log in with `hivemind login` to track team goals." }); + process.exit(0); +} + +const table = sqlIdent(tableNames().goals); +const where = filter === "mine" ? `WHERE owner = '${sqlStr(creds.userName)}'` : ""; + +let rows; +try { + rows = await query( + creds, + `SELECT goal_id, owner, status, content, version, created_at FROM "${table}" ${where} ORDER BY version DESC, created_at DESC LIMIT 200`, + ); +} catch (e) { + if (isMissingTableError(e?.message)) { + emit({ loggedOut: false, goals: [] }); + process.exit(0); + } + emit({ loggedOut: false, goals: [], message: "Could not load goals." }); + process.exit(0); +} + +const latest = new Map(); +for (const r of rows) { + const goalId = String(r.goal_id ?? ""); + if (!goalId || latest.has(goalId)) continue; + const text = String(r.content ?? "").split(/\r?\n/)[0].trim(); + latest.set(goalId, { + goalId, + owner: String(r.owner ?? ""), + status: String(r.status ?? ""), + text, + createdAt: String(r.created_at ?? ""), + }); +} + +const goals = [...latest.values()] + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, 50) + .map(({ goalId, owner, status, text }) => ({ goalId, owner, status, text })); + +emit({ loggedOut: false, goals }); diff --git a/harnesses/cursor/extension/scripts/load-rules.mjs b/harnesses/cursor/extension/scripts/load-rules.mjs new file mode 100644 index 00000000..4fef11da --- /dev/null +++ b/harnesses/cursor/extension/scripts/load-rules.mjs @@ -0,0 +1,69 @@ +/** + * List team rules from the Deeplake `hivemind_rules` table. + * + * Self-contained: reads credentials and queries the Deeplake HTTP endpoint + * directly (see lib/deeplake.mjs). Mirrors the latest-version-per-rule dedup + * in core src/rules/read.ts. Prints a RulesListResult JSON to stdout. + */ +import { loadCreds, query, sqlIdent, tableNames, isMissingTableError } from "./lib/deeplake.mjs"; + +const status = process.argv[2] || "active"; +const limit = parseInt(process.argv[3] || "10", 10); + +function emit(obj) { + process.stdout.write(JSON.stringify(obj)); +} + +const creds = loadCreds(); +if (!creds) { + emit({ loggedOut: true, rules: [], message: "Log in with `hivemind login` to manage team rules." }); + process.exit(0); +} + +const table = sqlIdent(tableNames().rules); + +let rows; +try { + rows = await query(creds, `SELECT id, rule_id, text, scope, status, assigned_by, version, created_at FROM "${table}" ORDER BY version DESC, created_at DESC, id DESC`); +} catch (e) { + // The rules table is created lazily by the CLI on first write. Until then + // a read 400s with "does not exist" — that just means no rules yet. + if (isMissingTableError(e?.message)) { + emit({ loggedOut: false, rules: [] }); + process.exit(0); + } + emit({ loggedOut: false, rules: [], message: "Could not load rules." }); + process.exit(0); +} + +const latest = new Map(); +for (const r of rows) { + const versionRaw = r.version; + const version = typeof versionRaw === "number" ? versionRaw : Number(versionRaw); + if (!Number.isFinite(version)) continue; + const ruleId = String(r.rule_id ?? ""); + if (!ruleId || latest.has(ruleId)) continue; + latest.set(ruleId, { + id: String(r.id ?? ""), + rule_id: ruleId, + text: String(r.text ?? ""), + status: String(r.status ?? ""), + assigned_by: String(r.assigned_by ?? ""), + version, + created_at: String(r.created_at ?? ""), + }); +} + +const filtered = [...latest.values()].filter((r) => (status === "all" ? true : r.status === status)); +filtered.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id)); + +emit({ + loggedOut: false, + rules: filtered.slice(0, limit).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..f37f4df0 --- /dev/null +++ b/harnesses/cursor/extension/scripts/load-session-summary.mjs @@ -0,0 +1,91 @@ +/** + * Load a session summary: remote Deeplake memory table first, local disk + * fallback second. + * + * Self-contained: reads credentials and queries the Deeplake HTTP endpoint + * directly (see lib/deeplake.mjs). Mirrors the resolution order in the core + * memory summary path. Prints a SessionSummaryResult JSON to stdout. + */ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { loadCreds, query, sqlIdent, sqlStr, tableNames } from "./lib/deeplake.mjs"; + +const sessionId = process.argv[2]; +const userArg = process.argv[3] ?? ""; + +function emit(obj) { + process.stdout.write(JSON.stringify(obj)); +} + +if (!sessionId || !/^[a-zA-Z0-9_-]{1,128}$/.test(sessionId)) { + emit({ 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 || !creds.userName) return { text: null, unreachable: false }; + let table; + try { + table = sqlIdent(tableNames().memory); + } catch { + return { text: null, unreachable: false }; + } + const vpath = `/summaries/${creds.userName}/${sessionId}.md`; + try { + const rows = await query( + creds, + `SELECT summary FROM "${table}" WHERE path = '${sqlStr(vpath)}' AND author = '${sqlStr(creds.userName)}' ` + + `AND summary <> '' ORDER BY last_update_date DESC LIMIT 1`, + 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 = loadCreds(); +const userName = userArg || creds?.userName || "unknown"; + +const remote = await readRemote(creds); +if (remote.text) { + emit({ text: remote.text, source: "remote", message: null }); + process.exit(0); +} + +const local = readLocal(userName); +if (local) { + emit({ text: local, source: "local", message: null }); + process.exit(0); +} + +if (remote.unreachable) { + emit({ + text: null, + source: "unreachable", + message: "Memory table unreachable. Showing no summary until connectivity returns.", + }); + process.exit(0); +} + +emit({ text: null, source: "missing", message: `No summary found for session ${sessionId}.` }); diff --git a/harnesses/cursor/extension/scripts/load-sessions.mjs b/harnesses/cursor/extension/scripts/load-sessions.mjs new file mode 100644 index 00000000..0bc9da47 --- /dev/null +++ b/harnesses/cursor/extension/scripts/load-sessions.mjs @@ -0,0 +1,65 @@ +/** + * List recent captured sessions from the Deeplake `sessions` table. + * + * Self-contained: reads credentials and queries the Deeplake HTTP endpoint + * directly. Mirrors the grouped listing in core src/commands/session-prune.ts + * (one row per session path, newest first). Sessions are scoped to the + * current repo's project when possible, falling back to all of the user's + * recent sessions. Prints RecentSession[] JSON to stdout. + */ +import { basename } from "node:path"; +import { loadCreds, query, sqlIdent, sqlStr, tableNames } from "./lib/deeplake.mjs"; + +const cwd = process.argv[2] || process.cwd(); + +function emit(arr) { + process.stdout.write(JSON.stringify(arr)); +} + +/** /sessions//___.jsonl -> sessionId */ +function extractSessionId(path) { + const m = String(path).match(/\/sessions\/[^/]+\/[^/]+_([^.]+)\.jsonl$/); + if (m) return m[1]; + return String(path).split("/").pop()?.replace(/\.jsonl$/, "") ?? String(path); +} + +const creds = loadCreds(); +if (!creds || !creds.userName) { + emit([]); + process.exit(0); +} + +const table = sqlIdent(tableNames().sessions); + +let rows; +try { + rows = await query( + creds, + `SELECT path, COUNT(*) as cnt, MAX(creation_date) as last_event, MAX(project) as project ` + + `FROM "${table}" WHERE author = '${sqlStr(creds.userName)}' ` + + `GROUP BY path ORDER BY last_event DESC LIMIT 100`, + ); +} catch { + emit([]); + process.exit(0); +} + +const all = rows.map((r) => { + const eventCount = Number(r.cnt) || 0; + return { + sessionId: extractSessionId(r.path), + endedAt: String(r.last_event ?? ""), + eventCount, + memorySearchCount: eventCount, + project: r.project ? String(r.project) : null, + hadRecall: eventCount > 0, + }; +}); + +// Prefer sessions from this repo's project; fall back to all when the +// project name does not line up with the captured `project` column. +const repoProject = basename(cwd); +const scoped = all.filter((s) => s.project && s.project === repoProject); +const result = (scoped.length > 0 ? scoped : all).slice(0, 20); + +emit(result); diff --git a/harnesses/cursor/extension/src/auth/api-key.ts b/harnesses/cursor/extension/src/auth/api-key.ts new file mode 100644 index 00000000..b7a78174 --- /dev/null +++ b/harnesses/cursor/extension/src/auth/api-key.ts @@ -0,0 +1,62 @@ +import * as vscode from "vscode"; +import { saveCredentialsFromToken } from "./device-flow"; +import { loadStoredCredentials } from "./detector"; +import { logSafe } from "../utils/output"; + +const DEFAULT_API_URL = "https://api.deeplake.ai"; + +export async function loginApiKey(): Promise { + const token = await vscode.window.showInputBox({ + prompt: "Enter your Hivemind API token", + password: true, + ignoreFocusOut: true, + placeHolder: "Paste token (never logged or stored in settings)", + }); + if (!token) return false; + + const apiUrl = process.env.HIVEMIND_API_URL ?? DEFAULT_API_URL; + try { + await saveCredentialsFromToken(token.trim(), apiUrl); + logSafe("Hivemind API key login succeeded."); + return true; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Invalid token"; + await vscode.window.showErrorMessage(`Login failed: ${msg}`); + return false; + } +} + +export async function promptLoginMethod(): Promise { + const choice = await vscode.window.showQuickPick( + [ + { label: "Browser sign-in (device flow)", id: "browser" }, + { label: "API key", id: "apikey" }, + { label: "Terminal (hivemind login)", id: "cli" }, + ], + { placeHolder: "Choose Hivemind login method" }, + ); + if (!choice) return false; + + if (choice.id === "browser") { + const { loginBrowserFlow } = await import("./device-flow"); + try { + await loginBrowserFlow(); + return true; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Login failed"; + await vscode.window.showErrorMessage(msg); + return false; + } + } + if (choice.id === "apikey") { + return loginApiKey(); + } + const { loginViaHivemindCli } = await import("./device-flow"); + return loginViaHivemindCli(); +} + +export function getActiveCredentialsSummary(): string | undefined { + const creds = loadStoredCredentials(); + if (!creds) return undefined; + return creds.userName ?? creds.orgName ?? "Logged in"; +} diff --git a/harnesses/cursor/extension/src/auth/detector.ts b/harnesses/cursor/extension/src/auth/detector.ts new file mode 100644 index 00000000..0589a6a9 --- /dev/null +++ b/harnesses/cursor/extension/src/auth/detector.ts @@ -0,0 +1,99 @@ +import { existsSync } from "node:fs"; +import type { AuthState } from "../types/health"; +import { credentialsPath } from "../utils/paths"; +import { readJson } from "../utils/fs-json"; +import { runHealthCheck } from "../health/checker"; +import { sanitizeApiUrl } from "./safe-url"; + +export interface StoredCredentials { + token: string; + orgId: string; + orgName?: string; + userName?: string; + workspaceId?: string; + apiUrl?: string; + savedAt: string; +} + +const DEFAULT_API_URL = "https://api.deeplake.ai"; + +export function loadStoredCredentials(): StoredCredentials | null { + return readJson(credentialsPath()); +} + +export function isCredentialFilePresent(): boolean { + return existsSync(credentialsPath()) && loadStoredCredentials() !== null; +} + +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`, { + headers: { + Authorization: `Bearer ${creds.token}`, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(8000), + }); + if (resp.status === 401 || resp.status === 403) return "logged_out"; + return resp.ok ? "online" : "offline"; + } catch { + return "offline"; + } +} + +export async function detectAuthState(): Promise { + const creds = loadStoredCredentials(); + const health = await runHealthCheck(); + const d3 = health.dimensions.find((d) => d.id === "d3"); + const cursorAgentLoggedIn = d3?.status === "ok"; + const cursorAgentMessage = d3?.message; + + if (!creds) { + return { + state: "logged_out", + cursorAgentLoggedIn, + cursorAgentMessage, + }; + } + + const identity = creds.userName ?? creds.orgName ?? creds.orgId; + const online = await validateCredentialsOnline(creds); + if (online === "offline") { + return { + state: "unknown_offline", + identity, + orgName: creds.orgName, + workspaceId: creds.workspaceId, + cursorAgentLoggedIn, + cursorAgentMessage, + }; + } + if (online === "logged_out") { + return { + state: "logged_out", + identity, + orgName: creds.orgName, + workspaceId: creds.workspaceId, + cursorAgentLoggedIn, + cursorAgentMessage, + }; + } + + return { + state: "logged_in", + identity, + orgName: creds.orgName, + workspaceId: creds.workspaceId, + cursorAgentLoggedIn, + cursorAgentMessage, + }; +} + +export function formatIdentity(auth: AuthState): string { + if (!auth.identity) return "Not logged in"; + const parts = [auth.identity]; + if (auth.orgName) parts.push(`org: ${auth.orgName}`); + if (auth.workspaceId) parts.push(`workspace: ${auth.workspaceId}`); + return parts.join(" · "); +} diff --git a/harnesses/cursor/extension/src/auth/device-flow.ts b/harnesses/cursor/extension/src/auth/device-flow.ts new file mode 100644 index 00000000..0ac2ea24 --- /dev/null +++ b/harnesses/cursor/extension/src/auth/device-flow.ts @@ -0,0 +1,147 @@ +import { execFileSync } from "node:child_process"; +import * as vscode from "vscode"; +import { loadStoredCredentials, type StoredCredentials } from "./detector"; +import { credentialsPath, deeplakeConfigDir } from "../utils/paths"; +import { logSafe } from "../utils/output"; +import { assertSafeCredentialFields, openExternalUrl, sanitizeApiUrl } from "./safe-url"; + +const DEFAULT_API_URL = "https://api.deeplake.ai"; + +interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +} + +interface DeviceTokenResponse { + access_token: string; + token_type: string; + expires_in: number; +} + +async function requestDeviceCode(apiUrl: string): Promise { + const base = sanitizeApiUrl(apiUrl, DEFAULT_API_URL); + const resp = await fetch(`${base}/auth/device/code`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!resp.ok) throw new Error(`Device flow unavailable: HTTP ${resp.status}`); + return resp.json() as Promise; +} + +async function pollForToken(deviceCode: string, apiUrl: string): Promise { + const base = sanitizeApiUrl(apiUrl, DEFAULT_API_URL); + const resp = await fetch(`${base}/auth/device/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ device_code: deviceCode }), + }); + if (resp.ok) return resp.json() as Promise; + if (resp.status === 400) { + const err = (await resp.json().catch(() => null)) as { error?: string } | null; + if (err?.error === "authorization_pending" || err?.error === "slow_down") return null; + if (err?.error === "expired_token") throw new Error("Device code expired. Try again."); + if (err?.error === "access_denied") throw new Error("Authorization denied."); + } + throw new Error(`Token polling failed: HTTP ${resp.status}`); +} + +async function apiGet(path: string, token: string, apiUrl: string): Promise { + const base = sanitizeApiUrl(apiUrl, DEFAULT_API_URL); + const resp = await fetch(`${base}${path}`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + if (!resp.ok) throw new Error(`API ${resp.status}`); + return resp.json(); +} + +async function apiPost(path: string, body: unknown, token: string, apiUrl: string): Promise { + const base = sanitizeApiUrl(apiUrl, DEFAULT_API_URL); + const resp = await fetch(`${base}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!resp.ok) throw new Error(`API ${resp.status}`); + return resp.json(); +} + +export async function saveCredentialsFromToken(token: string, apiUrl: string): Promise { + const safeApiUrl = sanitizeApiUrl(apiUrl, DEFAULT_API_URL); + const user = (await apiGet("/me", token, safeApiUrl)) as { id: string; name: string; email?: string }; + const userName = user.name || (user.email ? user.email.split("@")[0] : "unknown"); + const orgs = (await apiGet("/organizations", token, safeApiUrl)) as { id: string; name: string }[]; + if (!Array.isArray(orgs) || orgs.length === 0) throw new Error("No organizations found for this account."); + const org = orgs[0]; + const tokenData = (await apiPost( + "/users/me/tokens", + { name: `hivemind-extension-${Date.now()}`, duration: 365 * 24 * 3600, organization_id: org.id }, + token, + safeApiUrl, + )) as { token: { token: string } }; + + const creds: StoredCredentials = { + token: tokenData.token.token, + orgId: org.id, + orgName: org.name, + userName, + apiUrl: safeApiUrl, + savedAt: new Date().toISOString(), + }; + + assertSafeCredentialFields(creds); + + const { mkdirSync, writeFileSync } = await import("node:fs"); + mkdirSync(deeplakeConfigDir(), { recursive: true, mode: 0o700 }); + writeFileSync(credentialsPath(), JSON.stringify(creds, null, 2), { mode: 0o600 }); + return creds; +} + +export async function loginBrowserFlow(): Promise { + const apiUrl = sanitizeApiUrl(process.env.HIVEMIND_API_URL, DEFAULT_API_URL); + const code = await requestDeviceCode(apiUrl); + await openExternalUrl(code.verification_uri_complete); + await vscode.window.showInformationMessage( + `Complete sign-in in your browser. Code: ${code.user_code}`, + "Open browser again", + ).then(async (choice) => { + if (choice === "Open browser again") await openExternalUrl(code.verification_uri_complete); + }); + + const interval = Math.max(code.interval || 5, 5) * 1000; + const deadline = Date.now() + code.expires_in * 1000; + + return await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "Waiting for Hivemind sign-in…", cancellable: true }, + async (_progress, cancelToken) => { + while (Date.now() < deadline) { + if (cancelToken.isCancellationRequested) throw new Error("Login cancelled."); + await new Promise((r) => setTimeout(r, interval)); + const result = await pollForToken(code.device_code, apiUrl); + if (result) { + logSafe("Hivemind browser login succeeded."); + return saveCredentialsFromToken(result.access_token, apiUrl); + } + } + throw new Error("Device code expired."); + }, + ); +} + +export async function loginViaHivemindCli(): Promise { + try { + execFileSync("hivemind", ["login"], { stdio: "inherit", timeout: 300000 }); + return loadStoredCredentials() !== null; + } catch { + return false; + } +} diff --git a/harnesses/cursor/extension/src/auth/index.ts b/harnesses/cursor/extension/src/auth/index.ts new file mode 100644 index 00000000..719cfb58 --- /dev/null +++ b/harnesses/cursor/extension/src/auth/index.ts @@ -0,0 +1,4 @@ +export { detectAuthState, loadStoredCredentials, isCredentialFilePresent, formatIdentity } from "./detector"; +export { loginBrowserFlow, loginViaHivemindCli } from "./device-flow"; +export { loginApiKey, promptLoginMethod, getActiveCredentialsSummary } from "./api-key"; +export { logout } from "./logout"; diff --git a/harnesses/cursor/extension/src/auth/logout.ts b/harnesses/cursor/extension/src/auth/logout.ts new file mode 100644 index 00000000..ff1d9a68 --- /dev/null +++ b/harnesses/cursor/extension/src/auth/logout.ts @@ -0,0 +1,21 @@ +import { unlinkSync } from "node:fs"; +import * as vscode from "vscode"; +import { credentialsPath } from "../utils/paths"; +import { logSafe } from "../utils/output"; + +export async function logout(): Promise { + let removed = false; + try { + unlinkSync(credentialsPath()); + removed = true; + } catch { + removed = false; + } + + const message = removed + ? "Hivemind credentials cleared from ~/.deeplake/credentials.json. Hooks remain installed; shared memory is inactive until you log in again." + : "No credentials file found to remove. Hooks remain installed."; + + logSafe(message); + await vscode.window.showInformationMessage(message); +} diff --git a/harnesses/cursor/extension/src/auth/safe-url.ts b/harnesses/cursor/extension/src/auth/safe-url.ts new file mode 100644 index 00000000..1e8da5db --- /dev/null +++ b/harnesses/cursor/extension/src/auth/safe-url.ts @@ -0,0 +1,59 @@ +import * as vscode from "vscode"; + +const ALLOWED_AUTH_HOSTS = new Set([ + "api.deeplake.ai", + "app.deeplake.ai", + "auth.deeplake.ai", +]); + +/** Validate HTTPS URLs from the device-flow API before opening externally. */ +export function assertSafeExternalUrl(raw: string): URL { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error("Invalid verification URL from auth server."); + } + if (parsed.protocol !== "https:") { + throw new Error("Verification URL must use HTTPS."); + } + if (!ALLOWED_AUTH_HOSTS.has(parsed.hostname)) { + throw new Error(`Unexpected auth host: ${parsed.hostname}`); + } + return parsed; +} + +export async function openExternalUrl(raw: string): Promise { + try { + const parsed = assertSafeExternalUrl(raw); + return vscode.env.openExternal(vscode.Uri.parse(parsed.toString())); + } catch { + return false; + } +} + +export function sanitizeApiUrl(raw: string | undefined, fallback: string): string { + const candidate = raw ?? fallback; + const parsed = assertSafeExternalUrl(candidate.endsWith("/") ? candidate.slice(0, -1) : candidate); + return parsed.origin; +} + +const TOKENish = /^[\x21-\x7E]{8,4096}$/; + +export function assertSafeCredentialFields(fields: { + token: string; + orgId: string; + orgName?: string; + userName?: string; + apiUrl?: string; +}): void { + if (!TOKENish.test(fields.token)) throw new Error("Invalid token shape from auth server."); + if (!/^[a-zA-Z0-9_-]{1,128}$/.test(fields.orgId)) throw new Error("Invalid org id from auth server."); + if (fields.userName && !/^[a-zA-Z0-9._-]{1,128}$/.test(fields.userName)) { + throw new Error("Invalid user name from auth server."); + } + if (fields.orgName && fields.orgName.length > 256) { + throw new Error("Invalid org name from auth server."); + } + if (fields.apiUrl) sanitizeApiUrl(fields.apiUrl, fields.apiUrl); +} diff --git a/harnesses/cursor/extension/src/bridge/auto-sync.ts b/harnesses/cursor/extension/src/bridge/auto-sync.ts new file mode 100644 index 00000000..0277075c --- /dev/null +++ b/harnesses/cursor/extension/src/bridge/auto-sync.ts @@ -0,0 +1,44 @@ +import { runHivemindCli } from "../webview/data-bridge"; +import { logSafe } from "../utils/output"; +import { backfillCursorLinks, syncSkillsToCursor } from "./skill-sync"; + +/** + * Run skill pull fan-out and Cursor symlink sync on extension activation. + * Respects HIVEMIND_AUTOPULL_DISABLED (same contract as SessionStart auto-pull). + */ +export async function runAutoSyncOnActivation(projectRoot?: string): Promise { + if (process.env.HIVEMIND_AUTOPULL_DISABLED === "1") { + logSafe("Auto skill sync skipped (HIVEMIND_AUTOPULL_DISABLED=1)."); + return; + } + + const cwd = projectRoot ?? process.cwd(); + try { + const pullResult = await runHivemindCli( + ["skillify", "pull", "--all-users", "--to", "global"], + cwd, + ); + if (pullResult.ok) { + logSafe("Auto skill pull completed."); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logSafe(`Auto-pull skipped: ${msg}`); + } + + try { + const backfilled = backfillCursorLinks(projectRoot); + if (backfilled > 0) { + logSafe(`Backfilled Cursor links for ${backfilled} manifest entries.`); + } + const state = syncSkillsToCursor(projectRoot); + if (state.erroredCount > 0) { + logSafe( + `Cursor skill sync: ${state.syncedCount} synced, ${state.skippedCount} partial, ${state.erroredCount} failed.`, + ); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logSafe(`Cursor skill sync failed: ${msg}`); + } +} 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..3d4afe3e --- /dev/null +++ b/harnesses/cursor/extension/src/bridge/skill-sync.ts @@ -0,0 +1,361 @@ +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(); + + const seen = new Set(); + 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), + }); + seen.add(name); + } else if (existsSync(join(globalPath, "SKILL.md"))) { + out.push({ + dirName: name, + scope: "global", + path: globalPath, + shareScope: readSkillShareScope(globalPath), + }); + seen.add(name); + } + } + + // Reconcile with what the settings "skills synced" count reports: pulled + // skills (`--` dirs under the canonical root) are real, + // installed skills too. Surfacing them here keeps the Skills tab from + // claiming "no skills" while settings shows a non-zero synced count. + for (const dirName of listPulledSkillDirs(globalRoot)) { + if (seen.has(dirName)) continue; + const pulledPath = join(globalRoot, dirName); + if (!existsSync(join(pulledPath, "SKILL.md"))) continue; + const declared = readSkillShareScope(pulledPath); + out.push({ + dirName, + scope: "global", + path: pulledPath, + shareScope: declared === "unknown" ? "team" : declared, + }); + seen.add(dirName); + } + return out; +} + +export function skillDirLabel(dirName: string): string { + return dirName; +} + +export function basenameSkill(dirName: string): string { + return basename(dirName); +} diff --git a/harnesses/cursor/extension/src/extension.ts b/harnesses/cursor/extension/src/extension.ts new file mode 100644 index 00000000..fb2fc870 --- /dev/null +++ b/harnesses/cursor/extension/src/extension.ts @@ -0,0 +1,75 @@ +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"; + +let statusBarItem: vscode.StatusBarItem | undefined; +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); + statusBarItem.command = "hivemind.showStatus"; + 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; + statusBarItem!.tooltip = snap.tooltip; + statusBarItem!.backgroundColor = new vscode.ThemeColor(pres.backgroundColor); + }; + + context.subscriptions.push( + poller.onUpdate((snap) => updateBar(snap)), + vscode.commands.registerCommand("hivemind.pollHealthNow", () => poller!.pollOnce(workspaceRoot)), + ...registerHivemindCommands( + () => poller!.pollOnce(workspaceRoot), + () => { + void poller!.pollOnce(workspaceRoot).then((snap) => showStatusDetail(snap)); + }, + ), + vscode.commands.registerCommand("hivemind.openDashboard", () => { + DashboardPanel.createOrShow(context.extensionUri, context); + }), + ); + + registerDashboardWebview(context); + + void runAutoSyncOnActivation(workspaceRoot); + + poller.start(); + + if (!context.globalState.get("hivemind.onboardingPrompted")) { + void poller.pollOnce(workspaceRoot).then(async (snap) => { + if (snap.barState !== "healthy") { + const run = await vscode.window.showInformationMessage( + "Hivemind is not fully configured for Cursor yet.", + "Run onboarding", + "Later", + ); + if (run === "Run onboarding") { + await vscode.commands.executeCommand("hivemind.runOnboarding"); + } + } + await context.globalState.update("hivemind.onboardingPrompted", true); + }); + } +} + +export function deactivate(): void { + poller?.stop(); + statusBarItem?.dispose(); +} diff --git a/harnesses/cursor/extension/src/graph/editor-sync.ts b/harnesses/cursor/extension/src/graph/editor-sync.ts new file mode 100644 index 00000000..74f3bc60 --- /dev/null +++ b/harnesses/cursor/extension/src/graph/editor-sync.ts @@ -0,0 +1,130 @@ +import * as vscode from "vscode"; +import type { GraphNode, GraphSnapshot } from "./types"; + +export interface ParsedLocation { + startLine: number; + endLine: number; +} + +/** Parse `L` or `L-` (1-indexed). */ +export function parseSourceLocation(loc: string): ParsedLocation { + const m = loc.match(/^L(\d+)(?:-(\d+))?/); + if (!m) return { startLine: 1, endLine: 1 }; + const startLine = parseInt(m[1]!, 10); + const endLine = m[2] ? parseInt(m[2], 10) : startLine; + return { startLine, endLine }; +} + +function locationSpan(loc: string): { start: number; end: number } { + const { startLine, endLine } = parseSourceLocation(loc); + return { start: startLine, end: Math.max(startLine, endLine) }; +} + +function rangeSize(loc: string): number { + const { start, end } = locationSpan(loc); + return end - start + 1; +} + +/** Find the best-matching node for a file path and 1-indexed cursor line. */ +export function findNodesAtPosition( + snapshot: GraphSnapshot, + relativeFile: string, + line: number, +): GraphNode[] { + const normalized = relativeFile.replace(/\\/g, "/").replace(/^\//, ""); + const candidates = snapshot.nodes.filter((n) => n.source_file === normalized); + const enclosing = candidates.filter((n) => { + const { start, end } = locationSpan(n.source_location); + return line >= start && line <= end; + }); + const pool = enclosing.length > 0 ? enclosing : candidates.filter((n) => parseSourceLocation(n.source_location).startLine === line); + return pool.sort((a, b) => { + const sizeDiff = rangeSize(a.source_location) - rangeSize(b.source_location); + if (sizeDiff !== 0) return sizeDiff; + return a.id.localeCompare(b.id); + }); +} + +function toWorkspaceUri(repoRoot: string, sourceFile: string): vscode.Uri { + return vscode.Uri.file(`${repoRoot.replace(/\/$/, "")}/${sourceFile}`); +} + +/** Open a graph node in the editor and reveal its declaration range. */ +export async function openNodeInEditor( + node: GraphNode, + repoRoot: string, +): Promise<{ ok: boolean; stale?: boolean; message?: string }> { + const uri = toWorkspaceUri(repoRoot, node.source_file); + const { startLine, endLine } = parseSourceLocation(node.source_location); + try { + const doc = await vscode.workspace.openTextDocument(uri); + const lineCount = doc.lineCount; + const line = Math.min(Math.max(1, startLine), lineCount) - 1; + const end = Math.min(Math.max(line, endLine - 1), lineCount - 1); + const range = new vscode.Range(line, 0, end, doc.lineAt(end).text.length); + const editor = await vscode.window.showTextDocument(doc, { preview: false }); + editor.selection = new vscode.Selection(range.start, range.end); + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + const stale = startLine > lineCount; + return { + ok: true, + stale, + message: stale ? "Graph location may be stale relative to the open file." : undefined, + }; + } catch { + return { ok: false, message: `Could not open ${node.source_file}` }; + } +} + +export interface EditorGraphSyncHandle { + dispose(): void; +} + +/** + * Highlight graph nodes that match the active editor cursor. + * `onHighlight` receives node ids (empty when none match). + */ +export function startEditorToGraphSync( + snapshot: GraphSnapshot, + repoRoot: string, + onHighlight: (nodeIds: string[]) => void, + debounceMs = 200, +): EditorGraphSyncHandle { + let timer: ReturnType | undefined; + + const syncActive = (): void => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + onHighlight([]); + return; + } + const folder = vscode.workspace.getWorkspaceFolder(editor.document.uri); + const root = folder?.uri.fsPath ?? repoRoot; + const rel = vscode.workspace.asRelativePath(editor.document.uri, false).replace(/\\/g, "/"); + if (rel.startsWith("..")) { + onHighlight([]); + return; + } + const line = editor.selection.active.line + 1; + const matches = findNodesAtPosition(snapshot, rel, line); + onHighlight(matches.map((n) => n.id)); + }; + + const schedule = (): void => { + if (timer) clearTimeout(timer); + timer = setTimeout(syncActive, debounceMs); + }; + + const subs = [ + vscode.window.onDidChangeActiveTextEditor(schedule), + vscode.window.onDidChangeTextEditorSelection(schedule), + ]; + schedule(); + + return { + dispose(): void { + if (timer) clearTimeout(timer); + for (const s of subs) s.dispose(); + }, + }; +} diff --git a/harnesses/cursor/extension/src/graph/impact-overlay.ts b/harnesses/cursor/extension/src/graph/impact-overlay.ts new file mode 100644 index 00000000..53de7d9e --- /dev/null +++ b/harnesses/cursor/extension/src/graph/impact-overlay.ts @@ -0,0 +1,136 @@ +import { execFileSync } from "node:child_process"; +import type { GraphEdge, GraphNode, GraphSnapshot } from "./types"; + +const IMPACT_CAP = 80; +const MAX_DEPTH = 25; + +const SOURCE_GLOBS = ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.py", "*.pyi", ":(exclude)*.d.ts"]; + +export interface ImpactNodeEntry { + id: string; + depth: number; + via?: { rel: string; from: string }; +} + +export interface ImpactOverlayResult { + changedFiles: string[]; + originNodeIds: string[]; + dependents: ImpactNodeEntry[]; + totalDependents: number; + capped: boolean; + caveat: string; +} + +function listUnstagedSourceFiles(cwd: string): string[] { + try { + const out = execFileSync("git", ["diff", "--name-only", "--", ...SOURCE_GLOBS], { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + if (!out) return []; + return out.split(/\r?\n/).map((f) => f.replace(/\\/g, "/")).filter(Boolean); + } catch { + return []; + } +} + +function reverseBfsFromOrigins( + snap: GraphSnapshot, + originIds: string[], +): { dependents: ImpactNodeEntry[]; total: number; capped: boolean } { + const nodeIds = new Set(snap.nodes.map((n) => n.id)); + const incoming = new Map(); + for (const e of snap.links) { + if (!nodeIds.has(e.source)) continue; + const list = incoming.get(e.target); + if (list) list.push(e); + else incoming.set(e.target, [e]); + } + + const depthOf = new Map(); + const viaOf = new Map(); + let frontier = [...new Set(originIds)].filter((id) => nodeIds.has(id)); + for (const id of frontier) depthOf.set(id, 0); + + let depth = 0; + while (frontier.length > 0 && depth < MAX_DEPTH) { + depth++; + const next: string[] = []; + for (const id of frontier) { + const edges = (incoming.get(id) ?? []).slice().sort((a, b) => + a.source.localeCompare(b.source) || a.relation.localeCompare(b.relation)); + for (const e of edges) { + if (depthOf.has(e.source)) continue; + depthOf.set(e.source, depth); + viaOf.set(e.source, { rel: e.relation, from: id }); + next.push(e.source); + } + } + next.sort(); + frontier = next; + } + + const dependents: ImpactNodeEntry[] = []; + for (const [id, d] of depthOf.entries()) { + if (originIds.includes(id)) continue; + dependents.push({ id, depth: d, via: viaOf.get(id) }); + } + dependents.sort((a, b) => a.depth - b.depth || a.id.localeCompare(b.id)); + + const total = dependents.length; + const capped = total > IMPACT_CAP; + return { + dependents: dependents.slice(0, IMPACT_CAP), + total, + capped, + }; +} + +/** Compute git-diff-based impact visualization data for the graph canvas. */ +export function computeImpactOverlay(snapshot: GraphSnapshot, cwd: string): ImpactOverlayResult { + const changedFiles = listUnstagedSourceFiles(cwd); + const originNodeIds = snapshot.nodes + .filter((n) => changedFiles.includes(n.source_file)) + .map((n) => n.id) + .sort(); + + const caveat = + "Resolved graph edges only; real impact may be larger. Unstaged source changes mapped by file path."; + + if (changedFiles.length === 0) { + return { + changedFiles: [], + originNodeIds: [], + dependents: [], + totalDependents: 0, + capped: false, + caveat, + }; + } + + if (originNodeIds.length === 0) { + return { + changedFiles, + originNodeIds: [], + dependents: [], + totalDependents: 0, + capped: false, + caveat: `${caveat} Changed files have no matching graph nodes (new file or rebuild needed).`, + }; + } + + const { dependents, total, capped } = reverseBfsFromOrigins(snapshot, originNodeIds); + return { + changedFiles, + originNodeIds, + dependents, + totalDependents: total, + capped, + caveat, + }; +} + +export function nodesById(snapshot: GraphSnapshot): Map { + return new Map(snapshot.nodes.map((n) => [n.id, n])); +} diff --git a/harnesses/cursor/extension/src/graph/snapshot-loader.ts b/harnesses/cursor/extension/src/graph/snapshot-loader.ts new file mode 100644 index 00000000..366b2240 --- /dev/null +++ b/harnesses/cursor/extension/src/graph/snapshot-loader.ts @@ -0,0 +1,90 @@ +import type { DashboardDataEnvelope } from "../webview/data-bridge"; +import type { GraphEdge, GraphNode, GraphSnapshot } from "./types"; + +function isObject(v: unknown): v is Record { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +function isGraphSnapshotLike(raw: unknown): raw is GraphSnapshot { + if (!isObject(raw)) return false; + if (!Array.isArray(raw.nodes) || !Array.isArray(raw.links)) return false; + return raw.directed === true && raw.multigraph === true && isObject(raw.graph); +} + +/** Load a typed graph snapshot from a dashboard data envelope. */ +export function loadGraphSnapshotFromEnvelope(envelope: DashboardDataEnvelope): GraphSnapshot | null { + if (!envelope.graph?.snapshot) return null; + return parseGraphSnapshot(envelope.graph.snapshot); +} + +/** Parse and validate a raw snapshot payload from disk or the webview bridge. */ +export function parseGraphSnapshot(raw: unknown): GraphSnapshot | null { + if (!isGraphSnapshotLike(raw)) return null; + const nodes: GraphNode[] = []; + for (const n of raw.nodes) { + if (!isObject(n)) continue; + const id = typeof n.id === "string" ? n.id : null; + const label = typeof n.label === "string" ? n.label : null; + const kind = typeof n.kind === "string" ? n.kind : null; + const source_file = typeof n.source_file === "string" ? n.source_file : null; + const source_location = typeof n.source_location === "string" ? n.source_location : null; + const language = typeof n.language === "string" ? n.language : null; + if (!id || !label || !kind || !source_file || !source_location || !language) continue; + nodes.push({ + id, + label, + kind: kind as GraphNode["kind"], + source_file, + source_location, + language: language as GraphNode["language"], + exported: Boolean(n.exported), + signature: typeof n.signature === "string" ? n.signature : undefined, + doc: typeof n.doc === "string" ? n.doc : undefined, + fan_in: typeof n.fan_in === "number" ? n.fan_in : undefined, + fan_out: typeof n.fan_out === "number" ? n.fan_out : undefined, + is_entrypoint: typeof n.is_entrypoint === "boolean" ? n.is_entrypoint : undefined, + }); + } + if (nodes.length === 0 && raw.nodes.length > 0) return null; + + const links: GraphEdge[] = []; + for (const l of raw.links) { + if (!isObject(l)) continue; + const source = typeof l.source === "string" ? l.source : null; + const target = typeof l.target === "string" ? l.target : null; + const relation = typeof l.relation === "string" ? l.relation : null; + if (!source || !target || !relation) continue; + links.push({ + source, + target, + relation: relation as GraphEdge["relation"], + confidence: typeof l.confidence === "string" ? (l.confidence as GraphEdge["confidence"]) : undefined, + ord: typeof l.ord === "number" ? l.ord : undefined, + }); + } + + const graph = raw.graph; + return { + directed: true, + multigraph: true, + graph: { + schema_version: 1, + generator: "hivemind-graph", + commit_sha: typeof graph.commit_sha === "string" || graph.commit_sha === null ? graph.commit_sha : null, + repo_key: typeof graph.repo_key === "string" ? graph.repo_key : "", + }, + observation: isObject(raw.observation) + ? (raw.observation as GraphSnapshot["observation"]) + : { + ts: new Date().toISOString(), + branch: null, + worktree_path: "", + repo_project: "", + generator_version: "unknown", + source_files_extracted: 0, + source_files_skipped: 0, + }, + nodes, + links, + }; +} diff --git a/harnesses/cursor/extension/src/graph/types.ts b/harnesses/cursor/extension/src/graph/types.ts new file mode 100644 index 00000000..8a0c29e0 --- /dev/null +++ b/harnesses/cursor/extension/src/graph/types.ts @@ -0,0 +1,50 @@ +export type NodeKind = + | "function" + | "class" + | "method" + | "interface" + | "type_alias" + | "enum" + | "const" + | "module"; + +export type EdgeRelation = "imports" | "calls" | "extends" | "implements" | "method_of"; + +export type EdgeConfidence = "EXTRACTED" | "INFERRED" | "AMBIGUOUS"; + +export interface GraphNode { + id: string; + label: string; + kind: NodeKind; + source_file: string; + source_location: string; + language: string; + exported: boolean; + signature?: string; + doc?: string; + fan_in?: number; + fan_out?: number; + is_entrypoint?: boolean; +} + +export interface GraphEdge { + source: string; + target: string; + relation: EdgeRelation; + confidence?: EdgeConfidence; + ord?: number; +} + +export interface GraphSnapshot { + directed: true; + multigraph: true; + graph: { + schema_version: number; + generator: string; + commit_sha: string | null; + repo_key: string; + }; + observation?: Record; + nodes: GraphNode[]; + links: GraphEdge[]; +} diff --git a/harnesses/cursor/extension/src/health/checker.ts b/harnesses/cursor/extension/src/health/checker.ts new file mode 100644 index 00000000..9285196f --- /dev/null +++ b/harnesses/cursor/extension/src/health/checker.ts @@ -0,0 +1,305 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { HealthDimension, HealthResult } from "../types/health"; +import { + cursorBundleDir, + cursorHooksPath, + cursorPluginDir, + hivemindCursorBundleSrc, +} from "../utils/paths"; +import { readJson } from "../utils/fs-json"; + +const HIVEMIND_MARKER_KEY = "_hivemindManaged"; +const DOCS_URL = "https://github.com/thenotoriousllama/hivemind#quick-start"; + +interface CursorHookEntry { + type: "command" | "prompt"; + command?: string; + timeout?: number; + matcher?: string | Record; +} + +function resolveCliBin(cli: string, fallbacks: string[] = []): string | null { + const isWin = process.platform === "win32"; + try { + const out = execFileSync(isWin ? "where" : "which", [cli], { encoding: "utf-8" }); + const match = out.split(/\r?\n/).map((l) => l.trim()).find(Boolean); + if (match) return match; + } catch { + /* not on PATH */ + } + for (const fb of fallbacks) { + if (existsSync(fb)) return fb; + } + return null; +} + +function cursorAgentFallbacks(): string[] { + const home = homedir(); + return [ + "/usr/local/bin/cursor-agent", + "/usr/bin/cursor-agent", + join(home, ".npm-global", "bin", "cursor-agent"), + join(home, ".local", "bin", "cursor-agent"), + "/opt/homebrew/bin/cursor-agent", + ]; +} + +function probeVersion(bin: string): string | undefined { + try { + const out = execFileSync(bin, ["--version"], { encoding: "utf-8", timeout: 5000 }).trim(); + return out.split(/\r?\n/)[0] || undefined; + } catch { + return undefined; + } +} + +export function isHivemindEntry(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return false; + const cmd = (entry as { command?: string }).command; + if (typeof cmd !== "string") return false; + return cmd.replace(/\\/g, "/").includes("/.cursor/hivemind/bundle/"); +} + +function buildHookCmd(bundleFile: string, pluginDir: string, timeout: number): CursorHookEntry { + return { + type: "command", + command: `node "${join(pluginDir, "bundle", bundleFile)}"`, + timeout, + }; +} + +function buildHookCmdShellMatcher(bundleFile: string, pluginDir: string, timeout: number): CursorHookEntry { + return { + type: "command", + command: `node "${join(pluginDir, "bundle", bundleFile)}"`, + timeout, + matcher: "Shell", + }; +} + +export function buildHookConfig(pluginDir: string, version: string): Record { + return { + sessionStart: [buildHookCmd("session-start.js", pluginDir, 30)], + beforeSubmitPrompt: [buildHookCmd("capture.js", pluginDir, 10)], + preToolUse: [buildHookCmdShellMatcher("pre-tool-use.js", pluginDir, 30)], + postToolUse: [buildHookCmd("capture.js", pluginDir, 15)], + afterAgentResponse: [buildHookCmd("capture.js", pluginDir, 15)], + stop: [buildHookCmd("capture.js", pluginDir, 15), buildHookCmd("graph-on-stop.js", pluginDir, 30)], + sessionEnd: [buildHookCmd("session-end.js", pluginDir, 30), buildHookCmd("graph-on-stop.js", pluginDir, 30)], + }; +} + +function readExtensionVersion(): string { + try { + const pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8")); + return typeof pkg.version === "string" ? pkg.version : "0.0.0"; + } catch { + return "0.0.0"; + } +} + +function readBundleVersion(): string | undefined { + const stamp = join(cursorPluginDir(), ".hivemind_version"); + if (!existsSync(stamp)) return undefined; + try { + return readFileSync(stamp, "utf-8").trim() || undefined; + } catch { + return undefined; + } +} + +function checkHivemindCli(): HealthDimension { + const bin = resolveCliBin("hivemind"); + if (!bin) { + return { + id: "d1", + label: "Hivemind CLI", + status: "missing", + message: "Hivemind CLI not found on PATH.", + remediation: "Install Hivemind CLI to enable shared memory.", + installCommand: "npm install -g @deeplake/hivemind", + docsUrl: DOCS_URL, + }; + } + const version = probeVersion(bin); + return { + id: "d1", + label: "Hivemind CLI", + status: "ok", + message: version ? `Found ${bin} (${version})` : `Found ${bin}`, + }; +} + +function checkCursorAgentCli(): HealthDimension { + const bin = resolveCliBin("cursor-agent", cursorAgentFallbacks()); + if (!bin) { + return { + id: "d2", + label: "cursor-agent CLI", + status: "missing", + message: "cursor-agent not found. Session summaries are disabled until it is installed.", + remediation: "Install cursor-agent and ensure it is on PATH.", + docsUrl: "https://cursor.com/docs/agent/cli", + }; + } + const version = probeVersion(bin); + return { + id: "d2", + label: "cursor-agent CLI", + status: "ok", + message: version ? `Found ${bin} (${version})` : `Found ${bin}`, + }; +} + +function checkCursorAgentLogin(): HealthDimension { + const bin = resolveCliBin("cursor-agent", cursorAgentFallbacks()); + if (!bin) { + return { + id: "d3", + label: "cursor-agent login", + status: "missing", + message: "cursor-agent not installed; cannot verify login.", + }; + } + try { + execFileSync(bin, ["status"], { encoding: "utf-8", timeout: 8000 }); + return { + id: "d3", + label: "cursor-agent login", + status: "ok", + 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 = + exitCode === 401 || + exitCode === 403 || + /not logged/i.test(msg) || + /login required/i.test(msg); + return { + id: "d3", + label: "cursor-agent login", + status: loggedOut ? "logged_out" : "error", + message: loggedOut + ? "cursor-agent is installed but logged out. Summaries will silently fail until you log in." + : `Could not verify cursor-agent login: ${msg}`, + remediation: "Run `cursor-agent login` in a terminal or use Hivemind onboarding.", + }; + } +} + +function checkHooksWired(bundleVersion: string | undefined): { + dimension: HealthDimension; + wiredVersion?: string; +} { + const hooksPath = cursorHooksPath(); + const existing = readJson<{ hooks?: Record; [key: string]: unknown }>(hooksPath); + if (!existing?.hooks) { + return { + dimension: { + id: "d4", + label: "Hooks wired", + status: "not_wired", + message: "Hivemind hooks are not wired in ~/.cursor/hooks.json.", + remediation: "Use Wire / Refresh Hooks to install lifecycle hooks.", + }, + }; + } + + const events = ["sessionStart", "beforeSubmitPrompt", "preToolUse", "postToolUse", "afterAgentResponse", "stop", "sessionEnd"]; + const missing: string[] = []; + for (const ev of events) { + const entries = existing.hooks[ev]; + if (!Array.isArray(entries) || !entries.some(isHivemindEntry)) { + missing.push(ev); + } + } + + const marker = existing[HIVEMIND_MARKER_KEY] as { version?: string } | undefined; + const wiredVersion = marker?.version; + + if (missing.length > 0) { + return { + dimension: { + id: "d4", + label: "Hooks wired", + status: "not_wired", + message: `Missing Hivemind hooks for: ${missing.join(", ")}`, + remediation: "Use Wire / Refresh Hooks to complete wiring.", + }, + wiredVersion, + }; + } + + if (bundleVersion && wiredVersion && wiredVersion !== bundleVersion) { + return { + dimension: { + id: "d4", + label: "Hooks wired", + status: "stale", + message: `Hooks wired at v${wiredVersion}; current bundle is v${bundleVersion}.`, + remediation: "Refresh hooks to update to the current bundle version.", + }, + wiredVersion, + }; + } + + return { + dimension: { + id: "d4", + label: "Hooks wired", + status: "ok", + message: wiredVersion ? `All seven hooks wired (v${wiredVersion}).` : "All seven hooks wired.", + }, + wiredVersion, + }; +} + +export async function runHealthCheck(): Promise { + const bundlePresent = existsSync(cursorBundleDir()) && existsSync(join(cursorBundleDir(), "capture.js")); + 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 (!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") && provisionedPresent; + + return { + checkedAt: new Date().toISOString(), + dimensions, + bundlePresent: provisionedPresent, + bundleVersion, + wiredVersion, + allHealthy, + summariesDisabled, + }; +} + +export function getHivemindInstallCommand(): string { + return "npm install -g @deeplake/hivemind"; +} diff --git a/harnesses/cursor/extension/src/health/index.ts b/harnesses/cursor/extension/src/health/index.ts new file mode 100644 index 00000000..46282c98 --- /dev/null +++ b/harnesses/cursor/extension/src/health/index.ts @@ -0,0 +1,3 @@ +export { runHealthCheck, getHivemindInstallCommand } from "./checker"; +export { autoWireHooks, unwireHooks, setBundledExtensionSrc } from "./wirings"; +export type { WireResult } from "./wirings"; diff --git a/harnesses/cursor/extension/src/health/wirings.ts b/harnesses/cursor/extension/src/health/wirings.ts new file mode 100644 index 00000000..3f4368e2 --- /dev/null +++ b/harnesses/cursor/extension/src/health/wirings.ts @@ -0,0 +1,168 @@ +import { cpSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { buildHookConfig, isHivemindEntry } from "./checker"; +import { + cursorBundleDir, + cursorHooksPath, + cursorPluginDir, + hivemindCursorBundleSrc, +} from "../utils/paths"; +import { readJson, writeJsonIfChanged } from "../utils/fs-json"; + +const HIVEMIND_MARKER_KEY = "_hivemindManaged"; + +export interface WireResult { + ok: boolean; + changed: boolean; + message: string; + reloadRequired: boolean; +} + +function readExtensionVersion(): string { + try { + const pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8")); + return typeof pkg.version === "string" ? pkg.version : "0.0.0"; + } catch { + return "0.0.0"; + } +} + +function mergeHooks(existing: Record | null, pluginDir: string, version: string): Record { + const root = (existing ?? { version: 1, hooks: {} }) as { + version?: number; + hooks?: Record; + }; + if (!root.version) root.version = 1; + if (!root.hooks) root.hooks = {}; + const ours = buildHookConfig(pluginDir, version); + for (const [event, entries] of Object.entries(ours)) { + const prior = Array.isArray(root.hooks[event]) ? root.hooks[event] : []; + const stripped = prior.filter((e) => !isHivemindEntry(e)); + root.hooks[event] = [...stripped, ...entries]; + } + (root as Record)[HIVEMIND_MARKER_KEY] = { version }; + return root as unknown as Record; +} + +export function stripHooksFromConfig(existing: Record | null): Record | null { + if (!existing) return null; + const root = existing as { hooks?: Record }; + if (root.hooks) { + for (const event of Object.keys(root.hooks)) { + root.hooks[event] = (root.hooks[event] ?? []).filter((e) => !isHivemindEntry(e)); + if (root.hooks[event].length === 0) delete root.hooks[event]; + } + if (Object.keys(root.hooks).length === 0) delete root.hooks; + } + delete (existing as Record)[HIVEMIND_MARKER_KEY]; + 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 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(resolved.src, dest, { recursive: true, force: true }); + const version = readExtensionVersion(); + writeFileSync(join(cursorPluginDir(), ".hivemind_version"), `${version}\n`, "utf-8"); + return { ok: true, message: resolved.message }; +} + +export async function autoWireHooks(): Promise { + const provision = provisionBundle(); + if (!provision.ok) { + return { ok: false, changed: false, message: provision.message, reloadRequired: false }; + } + + const version = readExtensionVersion(); + const existing = readJson>(cursorHooksPath()); + const merged = mergeHooks(existing, cursorPluginDir(), version); + const changed = writeJsonIfChanged(cursorHooksPath(), merged); + + return { + ok: true, + changed, + message: changed + ? "Hooks wired. Reload Cursor to activate the new hooks." + : "Hooks already up to date; hooks.json was not rewritten.", + reloadRequired: changed, + }; +} + +export async function unwireHooks(): Promise { + const existing = readJson>(cursorHooksPath()); + if (!existing) { + return { ok: true, changed: false, message: "No hooks.json to clean.", reloadRequired: false }; + } + const stripped = stripHooksFromConfig(existing); + const meaningfulKeys = stripped + ? Object.keys(stripped).filter((k) => k !== "version").length + : 0; + + if (!stripped || meaningfulKeys === 0) { + if (existsSync(cursorHooksPath())) { + const { unlinkSync } = await import("node:fs"); + unlinkSync(cursorHooksPath()); + } + return { ok: true, changed: true, message: "Hivemind hooks removed.", reloadRequired: true }; + } + + const changed = writeJsonIfChanged(cursorHooksPath(), stripped); + return { + ok: true, + changed, + message: "Hivemind hooks stripped; foreign hooks preserved.", + reloadRequired: changed, + }; +} diff --git a/harnesses/cursor/extension/src/statusbar/commands.ts b/harnesses/cursor/extension/src/statusbar/commands.ts new file mode 100644 index 00000000..5bb324d6 --- /dev/null +++ b/harnesses/cursor/extension/src/statusbar/commands.ts @@ -0,0 +1,74 @@ +import * as vscode from "vscode"; +import type { StatusSnapshot } from "../types/health"; +import { autoWireHooks, unwireHooks, setBundledExtensionSrc } from "../health"; +import { promptLoginMethod, logout } from "../auth"; +import { getOutputChannel } from "../utils/output"; +import { wikiWorkerLogPath } from "../utils/paths"; +import { existsSync } from "node:fs"; +import { readFileSync } from "node:fs"; + +export async function runOnboarding(poll: () => Promise): Promise { + const wire = await autoWireHooks(); + if (!wire.ok) { + await vscode.window.showErrorMessage(wire.message); + } else if (wire.reloadRequired) { + await vscode.window.showInformationMessage(wire.message, "Reload Window").then((c) => { + if (c === "Reload Window") void vscode.commands.executeCommand("workbench.action.reloadWindow"); + }); + } + await promptLoginMethod(); + await poll(); +} + +export async function wireHooksCommand(): Promise { + const result = await autoWireHooks(); + 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 openLogsCommand(): void { + const ch = getOutputChannel(); + ch.show(true); + if (existsSync(wikiWorkerLogPath())) { + try { + const tail = readFileSync(wikiWorkerLogPath(), "utf-8").split(/\r?\n/).slice(-40).join("\n"); + ch.appendLine("--- wiki-worker.log (tail) ---"); + ch.appendLine(tail); + } catch { + ch.appendLine("(could not read wiki-worker.log)"); + } + } +} + +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, +): vscode.Disposable[] { + return [ + vscode.commands.registerCommand("hivemind.runOnboarding", () => runOnboarding(poll)), + vscode.commands.registerCommand("hivemind.login", () => promptLoginMethod().then(() => poll())), + 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/harnesses/cursor/extension/src/statusbar/detail-view.ts b/harnesses/cursor/extension/src/statusbar/detail-view.ts new file mode 100644 index 00000000..59bffe18 --- /dev/null +++ b/harnesses/cursor/extension/src/statusbar/detail-view.ts @@ -0,0 +1,62 @@ +import * as vscode from "vscode"; +import type { StatusSnapshot } from "../types/health"; +import { formatIdentity } from "../auth/detector"; +import { promptLoginMethod } from "../auth"; +import { autoWireHooks, getHivemindInstallCommand } from "../health"; +import { wireHooksCommand } from "./commands"; + +export async function showStatusDetail(snapshot: StatusSnapshot): Promise { + const items: vscode.QuickPickItem[] = snapshot.health.dimensions.map((d) => ({ + label: `${d.status === "ok" ? "$(check)" : "$(warning)"} ${d.label}`, + description: d.message, + detail: d.remediation, + })); + + items.push({ + label: "$(key) Hivemind identity", + description: formatIdentity(snapshot.auth), + }); + + if (snapshot.auth.cursorAgentLoggedIn === false) { + items.push({ + label: "$(warning) cursor-agent login", + description: snapshot.auth.cursorAgentMessage ?? "Log in to cursor-agent for summaries", + }); + } + + items.push({ label: "", kind: vscode.QuickPickItemKind.Separator, description: "Actions" }); + items.push({ label: "$(sign-in) Log in to Hivemind", description: "Browser or API key" }); + items.push({ label: "$(plug) Wire / refresh hooks", description: "Merge Hivemind lifecycle hooks" }); + items.push({ label: "$(rocket) Run full onboarding", description: "Wire hooks + log in" }); + + const d1 = snapshot.health.dimensions.find((d) => d.id === "d1"); + if (d1?.installCommand) { + items.push({ + label: "$(copy) Copy Hivemind install command", + description: d1.installCommand, + }); + } + + const pick = await vscode.window.showQuickPick(items, { + title: "Hivemind Status", + matchOnDescription: true, + }); + if (!pick) return; + + if (pick.label.includes("Log in")) { + await promptLoginMethod(); + return; + } + if (pick.label.includes("Wire")) { + await wireHooksCommand(); + return; + } + if (pick.label.includes("onboarding")) { + await vscode.commands.executeCommand("hivemind.runOnboarding"); + return; + } + if (pick.label.includes("Copy Hivemind install")) { + await vscode.env.clipboard.writeText(getHivemindInstallCommand()); + await vscode.window.showInformationMessage("Install command copied to clipboard."); + } +} diff --git a/harnesses/cursor/extension/src/statusbar/indicator.ts b/harnesses/cursor/extension/src/statusbar/indicator.ts new file mode 100644 index 00000000..c7d5c8a8 --- /dev/null +++ b/harnesses/cursor/extension/src/statusbar/indicator.ts @@ -0,0 +1,72 @@ +import type { AuthState, HealthResult, SkillSyncState, StatusBarState, StatusSnapshot } from "../types/health"; +import { formatIdentity } from "../auth/detector"; + +export function composeStatusBarState(health: HealthResult, auth: AuthState, skillSync?: SkillSyncState): StatusBarState { + if (auth.state === "unknown_offline") return "unknown"; + if (auth.state === "logged_out") return "logged_out"; + + const d1 = health.dimensions.find((d) => d.id === "d1"); + const d2 = health.dimensions.find((d) => d.id === "d2"); + const d3 = health.dimensions.find((d) => d.id === "d3"); + const d4 = health.dimensions.find((d) => d.id === "d4"); + + const hooksMissing = d4?.status === "not_wired" || d4?.status === "stale" || d4?.status === "error"; + const cliMissing = d1?.status === "missing" || d2?.status === "missing"; + + if (cliMissing || hooksMissing) return "not_configured"; + + const summaryImpaired = d2?.status !== "ok" || d3?.status === "logged_out"; + if (summaryImpaired) 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"; + return "not_configured"; +} + +const STATE_LABELS: Record = { + healthy: "$(check) Hivemind", + degraded: "$(warning) Hivemind degraded", + not_configured: "$(gear) Hivemind setup", + logged_out: "$(sign-in) Hivemind logged out", + unknown: "$(question) Hivemind unknown", +}; + +const STATE_COLORS: Record = { + healthy: "statusBarItem.prominentBackground", + degraded: "statusBarItem.warningBackground", + not_configured: "statusBarItem.errorBackground", + logged_out: "statusBarItem.errorBackground", + unknown: "statusBarItem.warningBackground", +}; + +export function buildTooltip(health: HealthResult, auth: AuthState, skillSync?: SkillSyncState): string { + const lines = health.dimensions.map((d) => `${d.label}: ${d.message}`); + lines.push(`Hivemind login: ${formatIdentity(auth)}`); + if (auth.cursorAgentLoggedIn === false && auth.cursorAgentMessage) { + lines.push(`cursor-agent: ${auth.cursorAgentMessage}`); + } + if (skillSync && skillSync.erroredCount > 0) { + lines.push(`Skill sync: ${skillSync.erroredCount} skill(s) not reaching Cursor agent`); + } + return lines.join("\n"); +} + +export function buildSnapshot(health: HealthResult, auth: AuthState, skillSync?: SkillSyncState): StatusSnapshot { + const barState = composeStatusBarState(health, auth, skillSync); + return { + barState, + health, + auth, + tooltip: buildTooltip(health, auth, skillSync), + skillSync, + }; +} + +export function getStatusBarPresentation(state: StatusBarState): { text: string; backgroundColor: string } { + return { + text: STATE_LABELS[state], + backgroundColor: STATE_COLORS[state], + }; +} diff --git a/harnesses/cursor/extension/src/statusbar/poller.ts b/harnesses/cursor/extension/src/statusbar/poller.ts new file mode 100644 index 00000000..746807d3 --- /dev/null +++ b/harnesses/cursor/extension/src/statusbar/poller.ts @@ -0,0 +1,54 @@ +import * as vscode from "vscode"; +import type { StatusSnapshot } from "../types/health"; +import { runHealthCheck } from "../health"; +import { detectAuthState } from "../auth"; +import { syncSkillsToCursor } from "../bridge/skill-sync"; +import { buildSnapshot } from "./indicator"; + +const DEFAULT_INTERVAL_MS = 60_000; + +export class HealthPoller { + private timer: NodeJS.Timeout | undefined; + private listeners = new Set<(snap: StatusSnapshot) => void>(); + private lastSnapshot: StatusSnapshot | undefined; + + onUpdate(listener: (snap: StatusSnapshot) => void): vscode.Disposable { + this.listeners.add(listener); + if (this.lastSnapshot) listener(this.lastSnapshot); + return new vscode.Disposable(() => this.listeners.delete(listener)); + } + + start(intervalMs = DEFAULT_INTERVAL_MS): void { + this.stop(); + void this.pollOnce(); + this.timer = setInterval(() => { + if (vscode.window.state.focused !== false) { + void this.pollOnce(); + } + }, intervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + async pollOnce(projectRoot?: string): Promise { + const health = await runHealthCheck(); + const auth = await detectAuthState(); + let skillSync; + 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; + for (const listener of this.listeners) listener(snap); + return snap; + } +} diff --git a/harnesses/cursor/extension/src/types/health.ts b/harnesses/cursor/extension/src/types/health.ts new file mode 100644 index 00000000..152dc2f8 --- /dev/null +++ b/harnesses/cursor/extension/src/types/health.ts @@ -0,0 +1,62 @@ +export type HealthDimensionStatus = "ok" | "missing" | "logged_out" | "stale" | "not_wired" | "error"; + +export interface HealthDimension { + id: "d1" | "d2" | "d3" | "d4"; + label: string; + status: HealthDimensionStatus; + message: string; + remediation?: string; + installCommand?: string; + docsUrl?: string; +} + +export interface HealthResult { + checkedAt: string; + dimensions: HealthDimension[]; + bundlePresent: boolean; + bundleVersion?: string; + wiredVersion?: string; + allHealthy: boolean; + summariesDisabled: boolean; +} + +export type AuthLoginState = "logged_in" | "logged_out" | "unknown_offline"; + +export interface AuthState { + state: AuthLoginState; + identity?: string; + orgName?: string; + workspaceId?: string; + cursorAgentLoggedIn?: boolean; + cursorAgentMessage?: string; +} + +export interface SkillSyncResult { + skillName: string; + status: "synced" | "skipped" | "errored"; + path?: string; + reason?: string; +} + +export interface SkillSyncState { + lastSyncAt?: string; + results: SkillSyncResult[]; + syncedCount: number; + skippedCount: number; + erroredCount: number; +} + +export type StatusBarState = + | "healthy" + | "degraded" + | "not_configured" + | "logged_out" + | "unknown"; + +export interface StatusSnapshot { + barState: StatusBarState; + health: HealthResult; + auth: AuthState; + tooltip: string; + skillSync?: SkillSyncState; +} diff --git a/harnesses/cursor/extension/src/utils/fs-json.ts b/harnesses/cursor/extension/src/utils/fs-json.ts new file mode 100644 index 00000000..d9b006c1 --- /dev/null +++ b/harnesses/cursor/extension/src/utils/fs-json.ts @@ -0,0 +1,29 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +export function readJson(path: string): T | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as T; + } catch { + return null; + } +} + +export function writeJson(path: string, obj: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(obj, null, 2) + "\n"); +} + +export function writeJsonIfChanged(path: string, obj: unknown): boolean { + const next = JSON.stringify(obj, null, 2) + "\n"; + if (existsSync(path)) { + try { + if (readFileSync(path, "utf-8") === next) return false; + } catch { + /* fall through */ + } + } + writeJson(path, obj); + return true; +} diff --git a/harnesses/cursor/extension/src/utils/output.ts b/harnesses/cursor/extension/src/utils/output.ts new file mode 100644 index 00000000..0c15ff3f --- /dev/null +++ b/harnesses/cursor/extension/src/utils/output.ts @@ -0,0 +1,20 @@ +import * as vscode from "vscode"; + +let channel: vscode.OutputChannel | undefined; + +export function getOutputChannel(): vscode.OutputChannel { + if (!channel) { + channel = vscode.window.createOutputChannel("Hivemind"); + } + return channel; +} + +/** Safe log: never pass secrets through this helper. */ +export function logSafe(message: string): void { + getOutputChannel().appendLine(message); +} + +export function logError(message: string, err?: unknown): void { + const detail = err instanceof Error ? err.message : err ? String(err) : ""; + logSafe(detail ? `${message}: ${detail}` : message); +} diff --git a/harnesses/cursor/extension/src/utils/paths.ts b/harnesses/cursor/extension/src/utils/paths.ts new file mode 100644 index 00000000..3a957f40 --- /dev/null +++ b/harnesses/cursor/extension/src/utils/paths.ts @@ -0,0 +1,48 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const HOME = homedir(); + +export function cursorHome(): string { + return join(HOME, ".cursor"); +} + +export function cursorHooksPath(): string { + return join(cursorHome(), "hooks.json"); +} + +export function cursorPluginDir(): string { + return join(cursorHome(), "hivemind"); +} + +export function cursorBundleDir(): string { + return join(cursorPluginDir(), "bundle"); +} + +export function deeplakeConfigDir(): string { + return join(HOME, ".deeplake"); +} + +export function credentialsPath(): string { + return join(deeplakeConfigDir(), "credentials.json"); +} + +export function wikiWorkerLogPath(): string { + return join(deeplakeConfigDir(), "wiki-worker.log"); +} + +export function hivemindGraphsHome(): string { + return process.env.HIVEMIND_GRAPHS_HOME ?? join(HOME, ".hivemind", "graphs"); +} + +export function monorepoRoot(): string { + return join(__dirname, "..", "..", "..", ".."); +} + +export function hivemindCursorBundleSrc(): string { + return join(monorepoRoot(), "harnesses", "cursor", "bundle"); +} + +export function workspaceRoot(fallback?: string): string { + return fallback ?? process.cwd(); +} diff --git a/harnesses/cursor/extension/src/webview/DashboardPanel.ts b/harnesses/cursor/extension/src/webview/DashboardPanel.ts new file mode 100644 index 00000000..18598a82 --- /dev/null +++ b/harnesses/cursor/extension/src/webview/DashboardPanel.ts @@ -0,0 +1,570 @@ +import * as vscode from "vscode"; +import { join } from "node:path"; +import { backfillCursorLinks, listLocalSkillsForPromoter, skillDirLabel, syncSkillsToCursor } from "../bridge/skill-sync"; +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"; +import { loadGraphSnapshotFromEnvelope, parseGraphSnapshot } from "../graph/snapshot-loader"; +import type { GraphSnapshot } from "../graph/types"; +import { logError } from "../utils/output"; +import { getDashboardHtml } from "./html/dashboard-shell"; +import { loadDashboardData, loadGoalsList, loadRecentSessions, loadRulesList, loadSessionSummary, runHivemindCli, runHivemindCliAsync, invalidateOrgStatsCache } from "./data-bridge"; + +type DashboardPane = "kpi" | "settings" | "sessions" | "graph" | "rules" | "skills" | "goals"; + +const NEXT_STEPS_SECTION_RE = /^##\s+Next Steps\s*$/im; + +interface WebviewInboundMessage { + type: string; + pane?: DashboardPane; + sessionId?: string; + text?: string; + ruleId?: string; + nodeId?: string; + dirName?: string; + orgName?: string; + workspaceName?: string; + rulesStatus?: string; + goalsFilter?: string; +} + +interface ParsedRule { + id: string; + status: string; + version: number; + author: string; + 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}$/; + +const PROMOTED_STEPS_KEY = "hivemind.promotedSteps"; + +function extractNextSteps(summary: string): string[] { + const match = NEXT_STEPS_SECTION_RE.exec(summary); + if (!match) return []; + const after = summary.slice(match.index + match[0].length); + const nextSection = after.search(/^##\s/m); + const block = nextSection >= 0 ? after.slice(0, nextSection) : after; + return block + .split(/\r?\n/) + .map((l) => l.replace(/^[-*]\s*/, "").trim()) + .filter((l) => l.length > 0 && !l.startsWith("#")); +} + +class DashboardController { + private editorSync: EditorGraphSyncHandle | undefined; + private snapshot: GraphSnapshot | null = null; + private rulesStatusFilter = "active"; + private goalsFilter: "mine" | "all" = "mine"; + 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, + localResourceRoots: [vscode.Uri.file(join(__dirname, ".."))], + }; + webview.html = getDashboardHtml(webview, vscode.Uri.file(__dirname)); + + disposables.push( + webview.onDidReceiveMessage((msg: WebviewInboundMessage) => { + void this.handleMessage(msg); + }), + ); + } + + 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 { + await this.pushDashboardData(); + await this.pushSettings(); + await this.pushSessions(); + await this.pushRules(); + await this.pushSkills(); + await this.pushGoals(); + } + + private post(message: Record): void { + void this.webview.postMessage(message); + } + + private async handleMessage(msg: WebviewInboundMessage): Promise { + try { + switch (msg.type) { + case "ready": + case "refresh": + 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 && 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: summary.text ?? summary.message ?? `No summary found for session ${msg.sessionId}.`, + degradedHint: summary.degradedHint, + summarySource: summary.source, + nextSteps, + promotedSteps: [...this.promotedSteps], + }); + } + break; + case "toggleEmbeddings": { + const status = await runHivemindCli(["embeddings", "status"], workspaceRoot()); + const enabled = status.stdout.toLowerCase().includes("enabled: true"); + const cmd = enabled ? ["embeddings", "disable"] : ["embeddings", "enable"]; + const result = await runHivemindCli(cmd, workspaceRoot()); + await this.pushSettings(); + await triggerHealthPoll(); + this.post({ + type: "actionResult", + target: "embeddings", + ok: result.ok, + message: result.ok ? (enabled ? "Embeddings disabled." : "Embeddings enabled.") : result.stderr, + }); + break; + } + case "buildGraph": { + 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({ + type: "actionResult", + target: "skillSync", + ok: state.erroredCount === 0, + message: `${state.syncedCount} synced, ${state.skippedCount} partial, ${state.erroredCount} failed.`, + }); + await this.pushSettings(); + await this.pushSkills(); + break; + } + case "rulesList": + if (msg.rulesStatus) this.rulesStatusFilter = msg.rulesStatus; + await this.pushRules(); + break; + case "goalsList": + if (msg.goalsFilter === "all" || msg.goalsFilter === "mine") this.goalsFilter = msg.goalsFilter; + await this.pushGoals(); + break; + case "goalAdd": + if (msg.text) { + const result = await runHivemindCli(["goal", "add", msg.text], workspaceRoot()); + this.post({ + type: "actionResult", + target: "goals", + ok: result.ok, + message: result.ok ? "Goal added." : result.stderr || "Failed to add goal.", + }); + if (result.ok) await this.pushGoals(); + } + break; + case "rulesAdd": + if (msg.text) { + const result = await runHivemindCli(["rules", "add", msg.text, "--scope", "team"], workspaceRoot()); + this.post({ + type: "actionResult", + target: "rules", + ok: result.ok, + message: result.ok ? "Rule added." : result.stderr, + }); + } + break; + case "rulesDone": + if (msg.ruleId) { + const result = await runHivemindCli(["rules", "done", msg.ruleId], workspaceRoot()); + this.post({ + type: "actionResult", + target: "rules", + ok: result.ok, + message: result.ok ? "Rule completed." : result.stderr, + }); + } + break; + case "rulesEdit": + if (msg.ruleId && msg.text) { + const result = await runHivemindCli(["rules", "edit", msg.ruleId, msg.text], workspaceRoot()); + this.post({ + type: "actionResult", + target: "rules", + ok: result.ok, + message: result.ok ? "Rule updated." : result.stderr, + }); + } + break; + case "promoteSkill": + if (msg.dirName) { + const skillName = msg.dirName; + const publishResult = await runHivemindCli( + ["skillify", "promote", skillName, "--scope", "team"], + workspaceRoot(), + ); + this.post({ + type: "actionResult", + target: "skillPromote", + ok: publishResult.ok, + message: publishResult.ok + ? publishResult.stdout.trim() || `Skill "${skillName}" promoted and published at team scope.` + : publishResult.stderr || "Promotion failed.", + }); + await this.pushSkills(); + } + 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", + ok: result.ok, + message: result.ok ? `Goal created: "${msg.text}"` : result.stderr || "Failed to create goal.", + }); + if (result.ok) await this.pushGoals(); + } + 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()); + this.post({ + type: "actionResult", + target: "orgSwitch", + ok: result.ok, + message: result.ok ? `Switched to org "${msg.orgName}".` : result.stderr || "Org switch failed.", + }); + if (result.ok) { + invalidateOrgStatsCache(); + await this.pushSettings(); + await this.pushDashboardData(); + await triggerHealthPoll(); + } + } + break; + case "computeImpact": + if (this.snapshot) { + const impact = computeImpactOverlay(this.snapshot, workspaceRoot()); + this.post({ type: "impact", impact }); + } + break; + case "graphNodeClick": + if (msg.nodeId && this.snapshot) { + const node = this.snapshot.nodes.find((n) => n.id === msg.nodeId); + if (node) { + const opened = await openNodeInEditor(node, workspaceRoot()); + if (opened.message) { + void vscode.window.showInformationMessage(opened.message); + } + } + } + break; + default: + break; + } + } catch (err: unknown) { + logError("Dashboard message handler failed", err); + this.post({ type: "error", message: "Action failed." }); + } + } + + private ensureEditorSync(): void { + if (!this.snapshot || this.editorSync) return; + this.editorSync = startEditorToGraphSync(this.snapshot, workspaceRoot(), (nodeIds) => { + 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.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(); + } + + private async pushSettings(): Promise { + const auth = await detectAuthState(); + const health = await runHealthCheck(); + 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), + healthSummary, + embeddingsStatus: emb.ok ? emb.stdout.split("\n")[0] ?? "unknown" : "unavailable", + skillSyncSummary: `Last sync: ${sync.syncedCount} ok, ${sync.erroredCount} failed`, + graphBuildMessage: "", + graphStatus, + openGoalsPreview, + }); + } + + private async pushSessions(): Promise { + const sessions = await loadRecentSessions(workspaceRoot()); + this.post({ type: "sessions", sessions }); + } + + private async pushRules(): Promise { + 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 pushGoals(): Promise { + const result = await loadGoalsList(this.goalsFilter); + if (result.loggedOut) { + this.post({ + type: "goals", + goals: [], + loggedOut: true, + message: result.message ?? "Log in with `hivemind login` to track goals.", + }); + return; + } + this.post({ type: "goals", goals: result.goals, 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, loggedOut: false }); + } +} + +export class DashboardPanel { + public static currentPanel: DashboardPanel | undefined; + public static readonly viewType = "hivemind.dashboardPanel"; + + private readonly panel: vscode.WebviewPanel; + private readonly controller: DashboardController; + private readonly disposables: vscode.Disposable[] = []; + + public static createOrShow(extensionUri: vscode.Uri, context: vscode.ExtensionContext): void { + const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One; + + if (DashboardPanel.currentPanel) { + DashboardPanel.currentPanel.panel.reveal(column); + void DashboardPanel.currentPanel.controller.refreshAll(); + return; + } + + const panel = vscode.window.createWebviewPanel( + DashboardPanel.viewType, + "Hivemind Dashboard", + column, + { enableScripts: true, retainContextWhenHidden: true }, + ); + + DashboardPanel.currentPanel = new DashboardPanel(panel, extensionUri, context); + } + + private constructor(panel: vscode.WebviewPanel, _extensionUri: vscode.Uri, context: vscode.ExtensionContext) { + this.panel = panel; + this.controller = new DashboardController(panel.webview, this.disposables, context.globalState); + + this.panel.onDidDispose(() => this.dispose(), null, this.disposables); + this.panel.onDidChangeViewState( + (e) => { + if (e.webviewPanel.visible) void this.controller.refreshAll(); + }, + null, + this.disposables, + ); + + void this.controller.refreshAll(); + } + + public dispose(): void { + DashboardPanel.currentPanel = undefined; + this.controller.dispose(); + this.panel.dispose(); + while (this.disposables.length) { + const d = this.disposables.pop(); + if (d) d.dispose(); + } + } +} + +class DashboardViewProvider implements vscode.WebviewViewProvider { + private controller: DashboardController | undefined; + + constructor(private readonly context: vscode.ExtensionContext) {} + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ): void { + const disposables: vscode.Disposable[] = []; + this.context.subscriptions.push(...disposables); + this.controller = new DashboardController(webviewView.webview, disposables, this.context.globalState); + webviewView.onDidChangeVisibility(() => { + if (webviewView.visible) void this.controller?.refreshAll(); + }); + void this.controller.refreshAll(); + } +} + +export function registerDashboardWebview(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.window.registerWebviewViewProvider("hivemind.dashboard", new DashboardViewProvider(context), { + webviewOptions: { retainContextWhenHidden: true }, + }), + ); +} 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..66e8f2ff --- /dev/null +++ b/harnesses/cursor/extension/src/webview/data-bridge.ts @@ -0,0 +1,461 @@ +import { spawn, execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +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; + eventCount?: number; + project?: string | null; + hadRecall?: boolean; +} + +export interface GoalsListResult { + loggedOut: boolean; + goals: Array<{ goalId: string; owner: string; status: string; text: string }>; + message?: string; +} + +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 []; + } +} + +/** Collapse the surface forms of a git remote URL. Mirrors core src/utils/repo-identity.ts. */ +function normalizeGitRemoteUrl(url: string): string { + let s = url.trim(); + const schemeMatch = s.match(/^([a-z][a-z0-9+.-]*):\/\//i); + const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null; + if (schemeMatch) s = s.slice(schemeMatch[0].length); + if (!scheme) { + const scp = s.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/); + if (scp) s = `${scp[1]}/${scp[2]}`; + } + s = s.replace(/^[^@/]+@/, ""); + const defaultPorts: Record = { http: "80", https: "443", ssh: "22", git: "9418" }; + if (scheme && defaultPorts[scheme]) { + s = s.replace(new RegExp(`^([^/]+):${defaultPorts[scheme]}(/|$)`), "$1$2"); + } + s = s.replace(/\.git\/?$/i, ""); + s = s.replace(/\/+$/, ""); + return s.toLowerCase(); +} + +/** + * Stable per-repo key: sha1 of the normalized git remote (fallback to cwd), + * first 16 hex chars. Mirrors core deriveProjectKey so the fallback resolves + * the SAME ~/.hivemind/graphs/ dir that `hivemind graph build` writes. + */ +function deriveProjectKey(cwd: string): { key: string; project: string } { + let project = basename(cwd); + let signature: string | null = null; + try { + const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8" }).trim(); + if (top) project = basename(top); + } catch { + /* not a git repo */ + } + try { + const raw = execFileSync("git", ["config", "--get", "remote.origin.url"], { cwd, encoding: "utf-8" }).trim(); + signature = raw ? normalizeGitRemoteUrl(raw) : null; + } catch { + signature = null; + } + const key = createHash("sha1").update(signature ?? cwd).digest("hex").slice(0, 16); + return { key, project }; +} + +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], { + 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." })); + }); +} + +export async function loadGoalsList(filter: "mine" | "all" = "mine"): Promise { + const scriptPath = join(__dirname, "..", "scripts", "load-goals.mjs"); + if (!existsSync(scriptPath)) { + return { loggedOut: true, goals: [], message: "Goals loader unavailable." }; + } + return new Promise((resolve) => { + const child = spawn(process.execPath, [scriptPath, filter], { + 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 GoalsListResult); + } catch { + resolve({ loggedOut: false, goals: [], message: "Failed to parse goals." }); + } + }); + child.on("error", () => resolve({ loggedOut: false, goals: [], message: "Failed to load goals." })); + }); +} + +/** 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..bd6ab804 --- /dev/null +++ b/harnesses/cursor/extension/src/webview/html/dashboard-shell.ts @@ -0,0 +1,907 @@ +import { randomBytes } from "node:crypto"; +import * as vscode from "vscode"; + +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); + // d3 is vendored under media/ and served via the webview resource scheme. + // Loading it from a remote CDN fails under the webview CSP/sandbox, which + // left the graph blank. extensionUri points at dist/, so media/ is one up. + const d3Uri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, "..", "media", "d3.v7.min.js")); + + 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/harnesses/cursor/extension/tsconfig.json b/harnesses/cursor/extension/tsconfig.json new file mode 100644 index 00000000..6e5e3a50 --- /dev/null +++ b/harnesses/cursor/extension/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "paths": { + "@hivemind/*": ["../../../src/*"] + }, + "baseUrl": "." + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/harnesses/cursor/extension/webpack.config.js b/harnesses/cursor/extension/webpack.config.js new file mode 100644 index 00000000..f89a6c5c --- /dev/null +++ b/harnesses/cursor/extension/webpack.config.js @@ -0,0 +1,36 @@ +//@ts-check +"use strict"; + +const path = require("path"); + +/** @type {import('webpack').Configuration} */ +module.exports = { + target: "node", + mode: "none", + entry: "./src/extension.ts", + output: { + path: path.resolve(__dirname, "dist"), + filename: "extension.js", + libraryTarget: "commonjs2", + devtoolModuleFilenameTemplate: "../[resource-path]", + }, + devtool: "source-map", + externals: { + vscode: "commonjs vscode", + }, + resolve: { + extensions: [".ts", ".js"], + alias: { + "@hivemind": path.resolve(__dirname, "../../../..", "src"), + }, + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: [{ loader: "ts-loader" }], + }, + ], + }, +}; diff --git a/harnesses/hermes/.gitkeep b/harnesses/hermes/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/openclaw/README.md b/harnesses/openclaw/README.md similarity index 100% rename from openclaw/README.md rename to harnesses/openclaw/README.md diff --git a/openclaw/openclaw.plugin.json b/harnesses/openclaw/openclaw.plugin.json similarity index 98% rename from openclaw/openclaw.plugin.json rename to harnesses/openclaw/openclaw.plugin.json index 6a59e38a..b67dfa63 100644 --- a/openclaw/openclaw.plugin.json +++ b/harnesses/openclaw/openclaw.plugin.json @@ -54,5 +54,5 @@ } } }, - "version": "0.7.93" + "version": "0.7.99" } diff --git a/openclaw/package.json b/harnesses/openclaw/package.json similarity index 96% rename from openclaw/package.json rename to harnesses/openclaw/package.json index 7c6fd8ad..4ec2b99b 100644 --- a/openclaw/package.json +++ b/harnesses/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "hivemind", - "version": "0.7.93", + "version": "0.7.99", "type": "module", "description": "Hivemind — cloud-backed persistent shared memory for AI agents, powered by DeepLake", "license": "Apache-2.0", diff --git a/openclaw/skills/SKILL.md b/harnesses/openclaw/skills/SKILL.md similarity index 100% rename from openclaw/skills/SKILL.md rename to harnesses/openclaw/skills/SKILL.md diff --git a/openclaw/skills/hivemind-goals/SKILL.md b/harnesses/openclaw/skills/hivemind-goals/SKILL.md similarity index 100% rename from openclaw/skills/hivemind-goals/SKILL.md rename to harnesses/openclaw/skills/hivemind-goals/SKILL.md diff --git a/openclaw/src/index.ts b/harnesses/openclaw/src/index.ts similarity index 98% rename from openclaw/src/index.ts rename to harnesses/openclaw/src/index.ts index 9fe32fb7..84c551be 100644 --- a/openclaw/src/index.ts +++ b/harnesses/openclaw/src/index.ts @@ -18,23 +18,23 @@ function loadSetupConfig(): Promise { return import("./setup-config.js"); } // Network-only helpers stay as static imports — auth.js no longer touches fs -// (its credential IO moved to ../../src/commands/auth-creds.js, which we load +// (its credential IO moved to ../../../src/commands/auth-creds.js, which we load // lazily below so esbuild emits it as a separate chunk). -import { requestDeviceCode, pollForToken, listOrgs, switchOrg, listWorkspaces, switchWorkspace, healDriftedOrgToken } from "../../src/commands/auth.js"; -import { DeeplakeApi } from "../../src/deeplake-api.js"; +import { requestDeviceCode, pollForToken, listOrgs, switchOrg, listWorkspaces, switchWorkspace, healDriftedOrgToken } from "../../../src/commands/auth.js"; +import { DeeplakeApi } from "../../../src/deeplake-api.js"; // Lazy-loaders for the fs-touching shared modules. Each becomes its own // esbuild chunk; the main openclaw bundle stays free of fs imports. -type CredsModule = typeof import("../../src/commands/auth-creds.js"); -type ConfigModule = typeof import("../../src/config.js"); +type CredsModule = typeof import("../../../src/commands/auth-creds.js"); +type ConfigModule = typeof import("../../../src/config.js"); let credsModulePromise: Promise | null = null; let configModulePromise: Promise | null = null; function loadCredsModule(): Promise { - if (!credsModulePromise) credsModulePromise = import("../../src/commands/auth-creds.js"); + if (!credsModulePromise) credsModulePromise = import("../../../src/commands/auth-creds.js"); return credsModulePromise; } function loadConfigModule(): Promise { - if (!configModulePromise) configModulePromise = import("../../src/config.js"); + if (!configModulePromise) configModulePromise = import("../../../src/config.js"); return configModulePromise; } async function loadCredentials() { @@ -50,23 +50,23 @@ async function loadConfig() { const m = await loadConfigModule(); return m.loadConfig(); } -import { sqlStr } from "../../src/utils/sql.js"; -import { deeplakeClientHeader } from "../../src/utils/client-header.js"; +import { sqlStr } from "../../../src/utils/sql.js"; +import { deeplakeClientHeader } from "../../../src/utils/client-header.js"; // Memory-access primitives reused directly from the CC/Codex hooks so the // openclaw agent gets the same search + read semantics (multi-word across // memory ∪ sessions, path filters, JSONB normalization, virtual /index.md). -import { searchDeeplakeTables, buildGrepSearchOptions, compileGrepRegex, normalizeContent, type GrepMatchParams } from "../../src/shell/grep-core.js"; -import { readVirtualPathContent } from "../../src/hooks/virtual-table-query.js"; +import { searchDeeplakeTables, buildGrepSearchOptions, compileGrepRegex, normalizeContent, type GrepMatchParams } from "../../../src/shell/grep-core.js"; +import { readVirtualPathContent } from "../../../src/hooks/virtual-table-query.js"; // Standalone embed client. Produces real document embeddings ONLY when the // canonical shared daemon at ~/.hivemind/embed-deps/embed-daemon.js is // present (deposited out-of-band by `hivemind embeddings install`). The // helper never installs transformers itself — that's explicit user opt-in // per src/user-config.ts. Returns null → caller writes NULL into // message_embedding (today's behavior, preserved on every failure mode). -import { tryEmbedStandalone, _setSpawnImpl } from "../../src/embeddings/standalone-embed-client.js"; -import { embeddingSqlLiteral } from "../../src/embeddings/sql.js"; +import { tryEmbedStandalone, _setSpawnImpl } from "../../../src/embeddings/standalone-embed-client.js"; +import { embeddingSqlLiteral } from "../../../src/embeddings/sql.js"; // Resolve sibling skillify-worker.js path at runtime via import.meta.url. The -// openclaw plugin is bundled to openclaw/dist/index.js, then installed to +// openclaw plugin is bundled to harnesses/openclaw/dist/index.js, then installed to // ~/.openclaw/extensions/hivemind/dist/index.js by install-openclaw.ts. The // worker bundle is its sibling at the same level. import { fileURLToPath } from "node:url"; diff --git a/openclaw/src/setup-config.ts b/harnesses/openclaw/src/setup-config.ts similarity index 100% rename from openclaw/src/setup-config.ts rename to harnesses/openclaw/src/setup-config.ts diff --git a/pi/extension-source/hivemind.ts b/harnesses/pi/extension-source/hivemind.ts similarity index 99% rename from pi/extension-source/hivemind.ts rename to harnesses/pi/extension-source/hivemind.ts index b8026a4e..ae3d47a0 100644 --- a/pi/extension-source/hivemind.ts +++ b/harnesses/pi/extension-source/hivemind.ts @@ -413,7 +413,7 @@ async function tryEmbedOverSocket(text: string, kind: "document" | "query"): Pro // Mirror of src/hooks/summary-state.ts (same dir, same JSON shape, shared // across CC/codex/cursor/hermes — session ids are UUIDs so collisions are // impossible). The pi extension increments totalCount on every captured -// event and spawns the bundled wiki-worker (see pi/bundle/wiki-worker.js) +// event and spawns the bundled wiki-worker (see harnesses/pi/bundle/wiki-worker.js) // when the threshold is hit. The worker, after generating the summary, // calls finalizeSummary() / releaseLock() against this same dir. So the // extension and the worker share state. @@ -754,7 +754,7 @@ function spawnWikiWorker( // ---------- skillify worker spawn --------------------------------------------- // // Mirror of src/skillify/spawn-skillify-worker.ts and src/skillify/triggers.ts — -// inlined here because pi/extension-source/hivemind.ts is shipped as raw .ts +// inlined here because harnesses/pi/extension-source/hivemind.ts is shipped as raw .ts // with zero non-builtin runtime dependencies (pi compiles + loads it at // extension-load time). The shared TypeScript modules under src/skillify/ // can't be imported from this file. diff --git a/library/README.md b/library/README.md new file mode 100644 index 00000000..717ee50d --- /dev/null +++ b/library/README.md @@ -0,0 +1,39 @@ +--- +ai_description: | + This is the root of the repository's documentation library (schema v2). + You own everything under library/ except notes/, which is human-only. + Sub-trees: knowledge/ (public and private docs), requirements/ (product + work: PRDs), issues/ (reactive bug/incident work: IRDs), notes/ (junk + drawer, read-only to agents). + Schema reference: legion-shared/standards/library-schema-v2.md. + Standardize script: pnpm standardize-library --repository . +human_description: | + Root of this repository's documentation library. + - knowledge/: reference documentation split by audience (public vs private) + - requirements/: planned product work (PRDs) with backlog/in-work/completed lifecycle + - issues/: reactive bug and incident work (IRDs) with same lifecycle + - notes/: unstructured scratch space — only humans write here + Run `pnpm standardize-library --repository ` to scaffold any missing structure. +--- + +# Library + +Documentation root for this repository. Schema version: **v2**. + +See [`legion-shared/standards/library-schema-v2.md`](../../legion-shared/standards/library-schema-v2.md) for the full specification. + +## Top-level layout + +| Folder | What goes here | +|---|---| +| `knowledge/public/` | End-user / customer-facing docs: overviews, guides, FAQs | +| `knowledge/private/` | Internal engineering and business docs: ADRs, standards, domain knowledge | +| `requirements/` | Product and feature work: PRDs in backlog/in-work/completed | +| `issues/` | Reactive bug and incident work: IRDs in backlog/in-work/completed | +| `notes/` | Human-only scratch space | + +## What does NOT belong here + +- Brand assets → `legion-shared/brands/` +- Wiki entity pages → `legion-wiki//wiki/` (derived, never edit) +- Library mirrors → `legion-wiki//library/` (derived, never edit) diff --git a/library/issues/README.md b/library/issues/README.md new file mode 100644 index 00000000..3e591906 --- /dev/null +++ b/library/issues/README.md @@ -0,0 +1,46 @@ +--- +ai_description: | + This folder contains all reactive bug and incident work (IRDs). + It is a PEER of requirements/, not nested under it. + Sub-folders: backlog/, in-work/, completed/ — same lifecycle as requirements/. + IRD folder naming: ird-<###>-/ + IRD numbers match the GitHub issue number for this repo. + Never invent IRD numbers — a GitHub issue must exist first. + IRDs are single-scope: one issue per IRD, no sub-IRDs. + Do NOT put PRDs here — those go in requirements/. +human_description: | + Reactive bug and incident work (IRDs), organized by lifecycle stage. + - backlog/: tracked issues with a fix plan, not yet started + - in-work/: issues currently being fixed + - completed/: resolved issues (move entire folder) + IRD numbers match GitHub issue numbers. Create an IRD only after the + GitHub issue exists. +--- + +# Issues + +Reactive bug and incident work (IRDs), organized by lifecycle state. + +## Sub-folders + +| Folder | State | Description | +|---|---|---| +| `backlog/` | Tracked | IRDs with a fix plan, not yet in progress | +| `in-work/` | Active | Issues currently being resolved | +| `completed/` | Resolved | Entire IRD folder moves here when the issue closes | + +## IRD folder structure + +``` +ird-042-stale-cache/ + ird-042-stale-cache-index.md single-scope fix plan + qa/ + ird-042-stale-cache-qa.md QA audit (written by quality-guardian) +``` + +## Naming rules + +- Folder: `ird-<###>-/` +- Index: `ird-<###>--index.md` +- IRD number = GitHub issue number (never invented) +- No sub-IRDs (scope one issue per IRD) diff --git a/library/issues/backlog/README.md b/library/issues/backlog/README.md new file mode 100644 index 00000000..88312cb6 --- /dev/null +++ b/library/issues/backlog/README.md @@ -0,0 +1,26 @@ +--- +ai_description: | + Contains IRD folders for tracked issues not yet in active fix work. + Create a new IRD here only AFTER the GitHub issue exists for this repo. + IRD folder: ird-<###>-/ where ### = GitHub issue number. + Must contain: ird-<###>--index.md (the fix plan) and qa/ folder. + IRDs are single-scope: do not add sub-IRDs. +human_description: | + IRDs planned but not yet in active fix work. Create IRDs here. + - Naming: ird-042-stale-cache/ with ird-042-stale-cache-index.md inside + - IRD number must match the GitHub issue number + - Create only after the GitHub issue exists + Move to in-work/ when fix work begins. +--- + +# Issues — Backlog + +Tracked issues with a fix plan, not yet in active resolution. + +## Creating a new IRD + +1. Confirm the GitHub issue number (e.g., #42). +2. Create `ird-042-/`. +3. Create `ird-042--index.md` — the single-scope fix plan. +4. Create `qa/` subfolder (empty; `quality-guardian` writes here). +5. No sub-IRDs — keep scope to one issue. diff --git a/library/issues/completed/README.md b/library/issues/completed/README.md new file mode 100644 index 00000000..ae7de558 --- /dev/null +++ b/library/issues/completed/README.md @@ -0,0 +1,13 @@ +--- +ai_description: | + Resolved IRD folders. Entire ird-<###>-/ folders move here from + in-work/ when the corresponding GitHub issue is closed and verified. + Read-only after landing — do NOT edit or re-open IRDs here. +human_description: | + Resolved issue folders. Move entire ird-NNN-slug/ here from in-work/ + when the GitHub issue is closed and the fix is confirmed. Read-only. +--- + +# Issues — Completed + +Resolved IRD folders. Entire `ird-<###>-/` folders land here when the GitHub issue closes and the fix is confirmed. Do not edit files here after landing. diff --git a/library/issues/in-work/README.md b/library/issues/in-work/README.md new file mode 100644 index 00000000..4f88c79e --- /dev/null +++ b/library/issues/in-work/README.md @@ -0,0 +1,13 @@ +--- +ai_description: | + IRD folders actively being resolved. Mirror of requirements/in-work/ + but for issues. Move entire ird-<###>-/ folder from backlog/ + here when fix work begins, then to completed/ when the issue closes. +human_description: | + IRDs currently being fixed. Move folder from backlog/ here when work + starts, and to completed/ when the GitHub issue is closed. +--- + +# Issues — In Work + +IRDs currently being resolved. Move from `backlog/` → here when fix work starts, then `completed/` when the GitHub issue closes. diff --git a/library/knowledge/README.md b/library/knowledge/README.md new file mode 100644 index 00000000..37bb88b1 --- /dev/null +++ b/library/knowledge/README.md @@ -0,0 +1,34 @@ +--- +ai_description: | + This folder contains all reference documentation for this repository, + split by intended audience: public/ for end-users, private/ for internal + team and AI agents. When filing a new doc, default to private/. Promote + to public/ only when the content is intentionally customer-facing. + Allowed writes: knowledge/public//.md and + knowledge/private//.md. ADRs always go in + knowledge/private/architecture/ADR--.md. + Never write to knowledge/ itself (write to the sub-folders). +human_description: | + Reference documentation split by audience. + - public/: docs that will eventually be surfaced to customers or published + - private/: internal engineering, architecture, business, and strategy docs + When adding a new doc, pick the right subdomain folder inside public/ or + private/. If the domain doesn't exist yet, create it. +--- + +# Knowledge + +Reference documentation for this repository, organized by audience. + +## Sub-folders + +| Folder | Audience | Typical content | +|---|---|---| +| `public/` | End-users, customers, external | Overviews, user guides, FAQs | +| `private/` | Internal team + AI agents | ADRs, standards, architecture, domain engineering docs | + +## Decision rule: public vs private + +> "Would I publish this on a help center or product docs site?" + +Yes → `public/`. No → `private/`. When in doubt, `private/`. diff --git a/library/knowledge/private/README.md b/library/knowledge/private/README.md new file mode 100644 index 00000000..df4ec153 --- /dev/null +++ b/library/knowledge/private/README.md @@ -0,0 +1,40 @@ +--- +ai_description: | + This folder contains internal engineering and business documentation. + ADRs MUST live in architecture/ADR--.md. + Engineering standards MUST live in standards/documentation-framework.md. + Other domain folders (/) are repo-specific and may be created as + needed (ai/, auth/, data/, frontend/, infrastructure/, integrations/, + marketing/, operations/, personas/, reporting/, roadmap/, scanners/, + security/, strategy/, etc.). + Do NOT file customer-facing content here (that goes in knowledge/public/). + Write path: library/knowledge/private//.md. +human_description: | + Internal engineering and business documentation. + - architecture/: Architecture Decision Records (ADRs) + - standards/: Documentation framework and coding standards + - /: Any repo-specific knowledge domain (ai/, auth/, data/, etc.) + Default landing zone for any doc that does not need to be customer-facing. + When creating a new domain folder, add a README.md explaining what belongs. +--- + +# Knowledge — Private + +Internal documentation for engineers, product, and AI agents. + +## Required sub-folders (always present) + +| Folder | Contents | +|---|---| +| `architecture/` | ADRs: `ADR--.md`. Locked decisions with context, alternatives, consequences. | +| `standards/` | `documentation-framework.md` and any repo-specific writing rules. | + +## Optional domain folders + +Create any of these as needed: `ai/`, `auth/`, `data/`, `frontend/`, `infrastructure/`, `integrations/`, `marketing/`, `operations/`, `personas/`, `reporting/`, `roadmap/`, `scanners/`, `security/`, `strategy/`, `reference/`, `-ux-ui/`. + +## What does NOT belong here + +- Customer-facing content (put in `knowledge/public/`) +- PRDs or IRDs (put in `requirements/` or `issues/`) +- Brand assets (put in `legion-shared/brands/`) diff --git a/library/knowledge/private/ai/embeddings-retrieval.md b/library/knowledge/private/ai/embeddings-retrieval.md new file mode 100644 index 00000000..a77acb5c --- /dev/null +++ b/library/knowledge/private/ai/embeddings-retrieval.md @@ -0,0 +1,122 @@ +# Embeddings and Retrieval + +> Category: AI | Version: 1.0 | Date: June 2026 | Status: Active + +How Hivemind produces and stores 768-dimensional sentence embeddings for session messages and wiki summaries, and how those vectors enable hybrid semantic-plus-lexical recall from the VFS. + +**Related:** +- [`session-capture.md`](session-capture.md) +- [`wiki-summary-workers.md`](wiki-summary-workers.md) +- [`../data/memory-virtual-filesystem.md`](../data/memory-virtual-filesystem.md) +- [`../data/deeplake-tables-schema.md`](../data/deeplake-tables-schema.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../../../../docs/EMBEDDINGS.md`](../../../../docs/EMBEDDINGS.md) + +--- + +## Why embeddings are optional + +Hivemind is designed to install in one command and work immediately. Bundling the nomic-embed model (`nomic-embed-text-v1.5`, ~130 MB) with `@huggingface/transformers` plus `onnxruntime-node` and `sharp` would add roughly 600 MB to the install. That is 60x the size of the core plugin for a feature most users do not need on day one. So embeddings are off by default and installed separately. + +Without embeddings, every `Grep` call over `~/.deeplake/memory/` uses BM25 / `ILIKE` matching on the `message` and `summary` text columns. That is fast and good enough for keyword-based recall. When embeddings are enabled, the same queries also score against the `message_embedding` and `summary_embedding` vectors, promoting semantically similar content even when it uses different words. + +--- + +## Installation + +The install command deposits the shared dependencies once into `~/.hivemind/embed-deps/` and symlinks every detected agent plugin to that directory, so the 600 MB cost is paid one time regardless of how many agents are wired up: + +```bash +hivemind embeddings install +``` + +Re-running the command after adding a new agent adds the new symlink; the npm install is skipped because the packages are already cached. After installation, restart your agents. From the next session, captured messages and summaries will include embeddings. + +--- + +## The daemon architecture + +The embedding computation runs in a long-lived daemon process. Running an ONNX model through `@huggingface/transformers` carries a cold-start cost of several seconds the first time (model download and JIT) and occupies about 200 MB of RAM. Doing that inside the capture hook on every event would be prohibitive. The daemon loads the model once and then serves embed requests over a per-user Unix socket until it idles out. + +```mermaid +flowchart LR + captureHook["capture.ts\n(or wiki-worker.ts)"] + embedClient["EmbedClient\n(src/embeddings/client.ts)"] + unixSocket["Unix socket\n(/tmp/hivemind-embed-.sock)"] + daemon["EmbedDaemon\n(src/embeddings/daemon.ts)"] + nomicModel["NomicEmbedder\n(nomic-embed-text-v1.5)"] + + captureHook -- "embed(text, 'document')" --> embedClient + embedClient -- "JSON request line" --> unixSocket + unixSocket --> daemon + daemon -- "inference" --> nomicModel + nomicModel -- "768-dim vector" --> daemon + daemon -- "JSON response line" --> unixSocket + unixSocket -- "number[] | null" --> embedClient +``` + +The socket path is `/tmp/hivemind-embed-.sock` and the pidfile is `/tmp/hivemind-embed-.pid`. Both are per-user (keyed by the process UID) so different users on the same machine never share a daemon. + +The daemon exits after `DEFAULT_IDLE_TIMEOUT_MS` (10 minutes) of inactivity so it does not consume RAM between sessions. The next embed call spawns a fresh one automatically. + +--- + +## `EmbedClient`: self-healing connect-and-spawn + +`EmbedClient` (in `src/embeddings/client.ts`) is the thin client used by hooks and workers. Its `embed(text, kind)` method is the only public API callers need. Key behaviors: + +**Auto-spawn on miss.** If the daemon is not running, the client attempts to spawn it using an `O_EXCL` pidfile lock so concurrent callers do not race to spawn duplicates. The lock caller writes its own PID as a placeholder immediately, then the daemon overwrites it with its own PID during startup. + +**Hello handshake and version check.** The first connection per `EmbedClient` instance sends a `hello` request. The daemon replies with `{ daemonPath, pid, protocolVersion }`. If the daemon's `daemonPath` file no longer exists on disk (the bundle that spawned it was garbage-collected by a marketplace upgrade), the client SIGTERMs the daemon, removes the sock and pid files, and lets the next call spawn a fresh one from the current bundle. + +**Transformers-missing recycle.** If the daemon is alive but cannot resolve `@huggingface/transformers` from its bundle path (typical after an upgrade left an older daemon process alive without its node_modules), it returns an error message containing `hivemind embeddings install`. The client treats this as a stuck daemon, SIGTERMs it, and returns `null` for the current call. The next call spawns fresh. + +**Non-blocking contract.** `embed()` always returns `number[] | null`. Hooks treat `null` as "skip the embedding column" and proceed with the INSERT. The embedding path never blocks or fails a capture. + +--- + +## Protocol + +The daemon and client communicate over the Unix socket using newline-delimited JSON. Each request includes an `op` field and an `id`: + +| Op | Direction | Purpose | +|---|---|---| +| `hello` | client to daemon | Version handshake; daemon replies with `daemonPath`, `pid`, `protocolVersion` | +| `ping` | client to daemon | Health check; daemon replies with `{ ready, model, dims }` | +| `embed` | client to daemon | Produce a vector; daemon replies with `{ id, embedding: number[] }` | + +Error responses always include `{ id, error: string }`. The client's timeout for any request is `DEFAULT_CLIENT_TIMEOUT_MS`. + +--- + +## Storing embeddings + +Two columns in the Deeplake schema carry embeddings: + +| Table | Column | Populated by | +|---|---|---| +| `sessions` | `message_embedding` | `src/hooks/capture.ts` on every event | +| `memory` | `summary_embedding` | `src/hooks/wiki-worker.ts` when uploading a summary | + +Both columns are nullable. When embeddings are disabled, the schema is unchanged and the column stores `NULL`. Enabling embeddings later fills new rows; old rows stay `NULL` and fall back to lexical ranking automatically. + +The SQL helper `embeddingSqlLiteral(embedding)` in `src/embeddings/sql.ts` serializes the vector for insertion. When the input is `null`, it emits `NULL`; when it is a `number[]`, it emits the Deeplake tensor literal format. + +--- + +## Lexical-only fallback + +If `@huggingface/transformers` is not present, Hivemind silently degrades to lexical-only mode. Capture continues, rows land in Deeplake, `Grep` works via BM25 and `ILIKE`, and the embedding columns stay `NULL`. The hook log notes `embeddings: no-transformers` once at session start. + +You can force lexical-only mode explicitly with `HIVEMIND_EMBEDDINGS=false`. This is useful in CI or air-gapped environments where the model download is not feasible. + +--- + +## Configuration + +| Env var | Default | Effect | +|---|---|---| +| `HIVEMIND_EMBEDDINGS` | `true` | Set to `false` to force lexical-only mode | +| `HIVEMIND_EMBED_DAEMON` | unset | Override the daemon entry path; resolved automatically when unset | +| `HIVEMIND_EMBED_DIMS` | `768` | Override output vector dimensionality (advanced) | +| `HIVEMIND_EMBED_IDLE_MS` | `600000` (10 min) | Daemon idle timeout before self-exit | diff --git a/library/knowledge/private/ai/session-capture.md b/library/knowledge/private/ai/session-capture.md new file mode 100644 index 00000000..4ec06de3 --- /dev/null +++ b/library/knowledge/private/ai/session-capture.md @@ -0,0 +1,154 @@ +# Session Capture + +> Category: AI | Version: 1.0 | Date: June 2026 | Status: Active + +How every prompt, tool call, and assistant response becomes a structured row in the Deeplake `sessions` table, and how the capture path feeds the summary and skillify workers. + +**Related:** +- [`wiki-summary-workers.md`](wiki-summary-workers.md) +- [`skillify-pipeline.md`](skillify-pipeline.md) +- [`embeddings-retrieval.md`](embeddings-retrieval.md) +- [`../architecture/session-lifecycle.md`](../architecture/session-lifecycle.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../data/deeplake-tables-schema.md`](../data/deeplake-tables-schema.md) +- [`../../../../docs/CAPTURE_TASKS.md`](../../../../docs/CAPTURE_TASKS.md) + +--- + +## What capture does and why it works this way + +Capture is the root of every other Hivemind feature. Without accurate, per-event rows in the `sessions` table, the wiki worker has nothing to summarize, the skillify miner has nothing to mine, and the VFS recall has nothing to serve. + +The design is intentionally minimal: one INSERT per event, no batching, no concatenation. Batching would mean a crash mid-session drops an entire batch; concatenation means the worker that later reads the session must parse a compound blob rather than individual rows. The per-row design keeps writes atomic and readers simple. + +Capture fires on four hook events in the Claude Code reference implementation: `UserPromptSubmit` (the user typed something), `PostToolUse` (a tool finished, captured asynchronously), `Stop` (the agent turn ended), and `SubagentStop` (a sub-agent finished). Each event carries a different payload and maps to a different row `type`. + +--- + +## Entry point: `src/hooks/capture.ts` + +The capture handler reads its input from stdin as a JSON object: + +```typescript +interface HookInput { + session_id: string; + hook_event_name?: string; + agent_id?: string; + agent_type?: string; + prompt?: string; // UserPromptSubmit + tool_name?: string; // PostToolUse + tool_input?: Record; + tool_response?: Record; + last_assistant_message?: string; // Stop / SubagentStop + agent_transcript_path?: string; + // ... +} +``` + +The handler dispatches on which payload fields are present and builds a typed entry: + +- If `prompt` is set: `type = "user_message"`, `content = prompt` +- If `tool_name` is set: `type = "tool_call"`, stores `tool_input` and `tool_response` as JSON strings +- If `last_assistant_message` is set: `type = "assistant_message"`, `content = last_assistant_message` +- Otherwise: log and return without writing + +All three branches share the same metadata block: `session_id`, `cwd`, `hook_event_name`, `agent_id`, `agent_type`, and a fresh ISO timestamp. + +--- + +## The INSERT + +The capture handler writes exactly one row to the `sessions` table per event. The SQL is built inline rather than using an ORM: + +```sql +INSERT INTO "sessions" ( + id, path, filename, message, message_embedding, + author, size_bytes, project, description, + agent, plugin_version, creation_date, last_update_date +) VALUES ( + '', '/sessions//.jsonl', '', + ''::jsonb, , + '', , '', '', + 'claude_code', '', '', '' +) +``` + +The `message` column is `jsonb`, so the entry object is serialized with `JSON.stringify` and only single-quotes are escaped (not backslashes or control characters) to preserve JSON structure. `sqlStr()` is deliberately not applied here because it would corrupt the JSON. + +If the `sessions` table does not exist yet (first run or workspace switch mid-session), the handler catches the error, calls `api.ensureSessionsTable()` to create it with the schema from `src/deeplake-schema.ts`, and retries the INSERT once. + +--- + +## Embedding the message + +Every captured message can include a 768-dimensional `message_embedding` vector for semantic recall. The embedding is produced by calling `EmbedClient.embed(line, "document")` before the INSERT: + +```typescript +const embedding = embeddingsDisabled() + ? null + : await new EmbedClient({ daemonEntry: resolveEmbedDaemonPath() }).embed(line, "document"); +const embeddingSql = embeddingSqlLiteral(embedding); +``` + +If embeddings are disabled (via `HIVEMIND_EMBEDDINGS=false` or absent `@huggingface/transformers`), `embedding` is `null` and the column is written as SQL `NULL`. The schema is always compatible, so the feature can be enabled later without a migration. + +The embedding client auto-spawns the nomic daemon on first use. The daemon path is resolved relative to the bundle directory so each plugin version uses its own daemon binary. See [`embeddings-retrieval.md`](embeddings-retrieval.md) for the full daemon lifecycle. + +--- + +## Guard conditions + +The handler exits immediately under several conditions to avoid spurious writes or recursive capture: + +| Condition | Check | +|---|---| +| Capture disabled globally | `HIVEMIND_CAPTURE=false` | +| Plugin disabled by the user | `isHivemindPluginEnabled()` returns false | +| Agent CLI gate | `entrypointPassesOnlyCliGate()` returns false | +| Inside the wiki worker itself | `HIVEMIND_WIKI_WORKER=1` (set by the worker on spawn) | + +The wiki worker sets `HIVEMIND_CAPTURE=false` in its environment so that the `claude -p` call it makes to generate the summary does not itself trigger another capture, which would create an infinite loop. + +--- + +## Downstream triggers from capture + +After the INSERT, capture does two more things before returning. + +**Periodic summary.** `maybeTriggerPeriodicSummary` increments a per-session counter stored in `~/.claude/hooks/summary-state/.json`. When the counter crosses the `HIVEMIND_SUMMARY_EVERY_N_MSGS` threshold (default 50) or the elapsed time exceeds `HIVEMIND_SUMMARY_EVERY_HOURS` (default 2), it acquires a lock and spawns the wiki worker as a detached background process. The lock prevents two concurrent periodic workers for the same session. + +**Stop-triggered skillify.** When `hook_event_name === "Stop"`, capture calls `tryStopCounterTrigger`, which increments a per-project counter and, every `HIVEMIND_SKILLIFY_EVERY_N_TURNS` turns (default 20), spawns the skillify worker. See [`skillify-pipeline.md`](skillify-pipeline.md) for what that worker does. + +```mermaid +flowchart TD + hookEvent["Hook event fires\n(UserPromptSubmit / PostToolUse / Stop / SubagentStop)"] + guardCheck["Guard checks\n(CAPTURE enabled, plugin on, not wiki-worker)"] + buildEntry["Build typed entry\n(user_message / tool_call / assistant_message)"] + embedCall["EmbedClient.embed()\n(null if disabled)"] + insertRow["INSERT INTO sessions"] + periodicCheck["maybeTriggerPeriodicSummary\n(bump counter; spawn wiki worker if threshold)"] + stopCheck{"hook_event = Stop?"} + skillifyCheck["tryStopCounterTrigger\n(spawn skillify worker every N turns)"] + + hookEvent --> guardCheck --> buildEntry --> embedCall --> insertRow --> periodicCheck --> stopCheck + stopCheck -- yes --> skillifyCheck + stopCheck -- no --> done["Return"] +``` + +--- + +## Self-heal on marketplace upgrade + +Marketplace auto-upgrades can leave the shared embeddings symlink pointing at a stale path. Capture runs `ensurePluginNodeModulesLink({ bundleDir })` once at module load to restore the symlink if it is broken. This is a best-effort operation: any error is swallowed so a broken symlink never blocks session capture. + +--- + +## Configuration + +| Env var | Default | Effect | +|---|---|---| +| `HIVEMIND_CAPTURE` | `true` | Set to `false` to disable all capture | +| `HIVEMIND_EMBEDDINGS` | `true` | Set to `false` to skip embedding and write `NULL` | +| `HIVEMIND_SUMMARY_EVERY_N_MSGS` | `50` | Message threshold for periodic summary trigger | +| `HIVEMIND_SUMMARY_EVERY_HOURS` | `2` | Time threshold for periodic summary trigger | +| `HIVEMIND_SKILLIFY_EVERY_N_TURNS` | `20` | Turn threshold for skillify worker trigger | diff --git a/library/knowledge/private/ai/skillify-pipeline.md b/library/knowledge/private/ai/skillify-pipeline.md new file mode 100644 index 00000000..97ab67dd --- /dev/null +++ b/library/knowledge/private/ai/skillify-pipeline.md @@ -0,0 +1,147 @@ +# Skillify Pipeline + +> Category: AI | Version: 1.0 | Date: June 2026 | Status: Active + +How Hivemind mines recent agent sessions to crystallize reusable `SKILL.md` files, propagate them to teammates, and keep the team's shared knowledge growing automatically. + +**Related:** +- [`session-capture.md`](session-capture.md) +- [`wiki-summary-workers.md`](wiki-summary-workers.md) +- [`../collaboration/team-skills-sharing.md`](../collaboration/team-skills-sharing.md) +- [`../architecture/session-lifecycle.md`](../architecture/session-lifecycle.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../data/deeplake-tables-schema.md`](../data/deeplake-tables-schema.md) +- [`../../../../docs/SKILLIFY.md`](../../../../docs/SKILLIFY.md) + +--- + +## The core idea + +Recurring patterns in agent sessions are worth codifying. When multiple sessions show the same approach to a problem (a particular migration idiom, a common debugging sequence, a non-obvious tool invocation pattern), that knowledge should not be locked inside those session transcripts. Skillify extracts the pattern, writes it as a `SKILL.md`, and propagates the file to every agent on the team. + +The pipeline has two halves. The first is local and happens at the end of every session: a stop-counter fires the skillify worker as a detached background process. The second is collaborative and happens at session start: every agent auto-pulls the latest skills from the Deeplake `skills` table into its own skill directory. + +--- + +## Trigger: when the worker fires + +The skillify worker fires on two triggers, both wired in `src/hooks/capture.ts` after each successful capture INSERT. + +The **stop-counter trigger** increments a per-project counter after each `Stop` event. When the counter reaches `HIVEMIND_SKILLIFY_EVERY_N_TURNS` (default 20), it resets the counter and spawns the worker. The **session-end trigger** fires unconditionally at `Stop` / `SessionEnd` regardless of the counter, catching tail-of-session knowledge that the mid-session counter might miss. + +Per-project counter state lives at `~/.deeplake/state/skillify/.json`. The project key is the SHA-1 of `git config remote.origin.url`, falling back to the absolute path for non-git directories. This means the counter is isolated per project: heavy use in one repo never triggers premature mining in another. + +A worker-lock mechanism prevents two concurrent skillify workers for the same project from running simultaneously. The lock is held in a file and released in the worker's `finally` block. + +--- + +## Worker: `src/skillify/skillify-worker.ts` + +The worker is spawned as a detached Node process with its configuration serialized to a temp JSON file. The invocation looks like: + +``` +node skillify-worker.js /tmp/hivemind-skillify-/config.json +``` + +### Step 1: fetch candidate sessions + +The worker queries the `sessions` table for the last 10 sessions in scope, ordered by the most recent message timestamp. "In scope" means: + +- `scope=me`: filtered to `author = ` +- `scope=team` with a populated team list: filtered to `author IN ()` + +The watermark (`state.lastDate`) prevents re-mining sessions already processed. Candidate sessions are filtered to exclude the session that triggered the worker (the in-flight session is not yet fully captured). + +### Step 2: extract prompt/answer pairs + +Each session's rows are fetched and passed through `extractPairs()` (from `src/skillify/extractors/`), which pairs user prompts with the agent's next assistant message, drops tool calls and thinking blocks, and returns `Pair[]` objects. Each pair carries its session ID and agent label. The pairs are then rendered into a text block, capped at 2,000 characters per pair and 40,000 characters total for the gate prompt. + +### Step 3: build and run the gate prompt + +The worker builds a gate prompt containing the existing project skills (capped at 30,000 characters) and the extracted pairs. The prompt instructs the gate model to return one of three verdicts: + +| Verdict | Meaning | +|---|---| +| `KEEP ` | Write a new skill file | +| `MERGE ` | Update an existing skill, bump version | +| `SKIP ` | Pattern is one-off, generic, or already covered | + +KEEP fires only when the pattern recurs across at least three exchanges, is non-obvious, and is not already covered. The precision-over-recall stance is explicit in the prompt: a missed skill is invisible, but a false skill erodes trust. + +The gate call shells out to the host agent's own CLI so no separate API key is needed: + +| Agent | Gate command | +|---|---| +| claude_code | `claude -p --no-session-persistence --model haiku --permission-mode bypassPermissions` | +| codex | `codex exec --dangerously-bypass-approvals-and-sandbox ` | +| cursor | `cursor-agent --print --model --force --output-format text ` | +| hermes | `hermes -z --provider -m --yolo --ignore-user-config` | + +The gate call runs synchronously with a 120-second timeout. The worker reads the verdict from the file the model was asked to write (`verdict.json` in the temp dir), or falls back to parsing the stdout if the model printed JSON instead. + +### Step 4: write the skill file + +On a `KEEP` verdict, `writeNewSkill()` creates a new `SKILL.md` under the configured skills root: + +- `install=project`: `/.claude/skills//SKILL.md` +- `install=global`: `~/.claude/skills//SKILL.md` + +On a `MERGE` verdict, `mergeSkill()` opens the existing file, updates the body and bumps the version in the frontmatter. If the MERGE target does not exist locally (the gate hallucinated a name from the user's global skills), the worker falls back to `writeNewSkill()` so the body is not lost. + +The `SKILL.md` includes YAML frontmatter with provenance metadata: `source_sessions`, `version`, `created_by_agent`, and timestamps. + +### Step 5: record to the Deeplake skills table + +After a successful local write, the worker inserts a row into the `skills` table for org-wide provenance. This is the mechanism by which teammates discover each other's mined skills. The INSERT uses the append-only pattern (never UPDATE) to sidestep Deeplake's UPDATE-coalescing quirk. + +Cross-author merges auto-promote the scope from `me` to `team` in the recorded row, so future `pull` commands know the skill is co-owned. + +```mermaid +flowchart TD + stopEvent["Stop event fires\n(every N turns)"] + fetchSessions["Fetch last 10 sessions\n(in scope, past watermark)"] + extractPairs["Extract prompt/answer pairs\n(strip tool calls, thinking)"] + buildPrompt["Build gate prompt\n(existing skills + pairs)"] + runGate["Run gate CLI\n(claude -p haiku / codex / cursor-agent / hermes)"] + parseVerdict["Parse verdict\n(file or stdout JSON)"] + keepBranch["KEEP: writeNewSkill()"] + mergeBranch["MERGE: mergeSkill()"] + skipBranch["SKIP: advance watermark"] + recordDeeplake["INSERT into skills table\n(org-wide provenance)"] + advanceWatermark["Advance watermark\n(oldest mined session)"] + + stopEvent --> fetchSessions --> extractPairs --> buildPrompt --> runGate --> parseVerdict + parseVerdict -- KEEP --> keepBranch --> recordDeeplake --> advanceWatermark + parseVerdict -- MERGE --> mergeBranch --> recordDeeplake --> advanceWatermark + parseVerdict -- SKIP --> skipBranch +``` + +--- + +## Watermark semantics + +The watermark is set to the date of the **oldest** mined session, not the newest. This is deliberate: setting it to the newest session would permanently skip any session older than the LIMIT cutoff that did not fit into the current batch. Setting it to the oldest means the next run re-sees the same batch (yielding SKIP when nothing changed, which is harmless) but also picks up any older sessions it missed. + +--- + +## Pull and auto-pull + +Once a skill row exists in the `skills` table, any teammate can pull it with `hivemind skillify pull`. The pull writes to `~/.claude/skills/--/SKILL.md` (the `--` suffix keeps cross-author skills with the same name disjoint) and fans out symlinks into the skill roots of every other detected agent (`~/.agents/skills/`, `~/.hermes/skills/`, `~/.pi/agent/skills/`) so all agents find the file without a separate copy. + +Auto-pull runs at every session start. The pull is idempotent: it skips a file when the local version is at or newer than the remote. The call is bounded by a 5-second timeout and swallows all errors, so a slow or unavailable Deeplake never blocks a session from starting. + +--- + +## Configuration + +| Env var | Default | Effect | +|---|---|---| +| `HIVEMIND_SKILLIFY_EVERY_N_TURNS` | `20` | Stop-counter threshold for mid-session trigger | +| `HIVEMIND_SKILLS_TABLE` | `skills` | Deeplake table name for org-wide provenance | +| `HIVEMIND_SKILLIFY_WORKER` | unset | Recursion guard; set to `1` automatically inside the worker | +| `HIVEMIND_CURSOR_MODEL` | `auto` | (cursor only) Model passed to the gate call | +| `HIVEMIND_HERMES_PROVIDER` | `openrouter` | (hermes only) Provider for the gate call | +| `HIVEMIND_HERMES_MODEL` | `anthropic/claude-haiku-4-5` | (hermes only) Model for the gate call | +| `HIVEMIND_AUTOPULL_DISABLED` | unset | Set to `1` to disable auto-pull at session start | + +Logs write to `~/.claude/hooks/skillify.log`. Each line shows the session pool mined, the gate verdict, and whether a file was written. diff --git a/library/knowledge/private/ai/wiki-summary-workers.md b/library/knowledge/private/ai/wiki-summary-workers.md new file mode 100644 index 00000000..df9fb650 --- /dev/null +++ b/library/knowledge/private/ai/wiki-summary-workers.md @@ -0,0 +1,166 @@ +# Wiki Summary Workers + +> Category: AI | Version: 1.0 | Date: June 2026 | Status: Active + +How Hivemind generates, stores, and incrementally updates AI-written wiki summaries for each session, and how those summaries power the VFS recall surface. + +**Related:** +- [`session-capture.md`](session-capture.md) +- [`embeddings-retrieval.md`](embeddings-retrieval.md) +- [`skillify-pipeline.md`](skillify-pipeline.md) +- [`../architecture/session-lifecycle.md`](../architecture/session-lifecycle.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../data/deeplake-tables-schema.md`](../data/deeplake-tables-schema.md) +- [`../../../../docs/SUMMARIES.md`](../../../../docs/SUMMARIES.md) + +--- + +## What summaries are for + +Raw session rows in the `sessions` table are precise but verbose. Searching across them for "what did we decide about the database schema last week" would require ranking thousands of individual messages. Summaries solve this by collapsing each session into a structured markdown document that names entities, decisions, files modified, and open questions. That document is what shows up when you `Grep` across `~/.deeplake/memory/` or follow links from `~/.deeplake/memory/index.md`. + +Summaries also carry a `summary_embedding` vector so semantic recall can promote a session even when the search terms do not match the exact words used at the time. + +--- + +## Trigger conditions + +Each agent fires a wiki worker on two triggers: + +| Trigger | When | +|---|---| +| **Final** | At session end: `Stop`, `SessionEnd`, or `session_shutdown`, once per session | +| **Periodic** | Mid-session, when messages since last summary reach `HIVEMIND_SUMMARY_EVERY_N_MSGS` (default 50) OR elapsed time since last summary reaches `HIVEMIND_SUMMARY_EVERY_HOURS` (default 2) | + +The periodic threshold check lives inside `maybeTriggerPeriodicSummary()` in `src/hooks/capture.ts`. After each capture INSERT, the function bumps a per-session counter in `~/.claude/hooks/summary-state/.json` and calls `shouldTrigger()` to decide whether to proceed. + +A lock file at `~/.claude/hooks/summary-state/.lock` prevents two workers from running concurrently for the same session. If the lock is already held (an earlier trigger's worker is still running), the new trigger is suppressed. The lock is always released in the worker's `finally` block. + +A sidecar JSON at `~/.claude/hooks/summary-state/.json` tracks `{ lastSummaryAt, lastSummaryCount, totalCount }`. The directory is shared across all agents because session IDs are UUIDs and never collide. The file is never deleted, so resuming a session via `--resume` or `--continue` picks up the count from where it left off. + +--- + +## Worker: `src/hooks/wiki-worker.ts` + +The worker runs as a detached Node process, spawned by `src/hooks/spawn-wiki-worker.ts`. The spawn function serializes a `WorkerConfig` object to a temp JSON file and invokes: + +``` +node wiki-worker.js /tmp/hivemind-wiki-/config.json +``` + +The worker sets `HIVEMIND_WIKI_WORKER=1` and `HIVEMIND_CAPTURE=false` in the subprocess environment to prevent the `claude -p` call inside from triggering its own capture loop. + +### Step 1: fetch session events + +The worker queries the `sessions` table for all rows belonging to the session, ordered by `creation_date` ascending: + +```sql +SELECT message, creation_date FROM "sessions" +WHERE path LIKE '/sessions/%%' +ORDER BY creation_date ASC +``` + +Because capture hooks INSERT asynchronously, Deeplake's eventual-consistency model means rows can lag behind the `SessionEnd` event. The worker retries with linear backoff up to `HIVEMIND_WIKI_EVENT_RETRIES` (default 5) times at `HIVEMIND_WIKI_EVENT_BACKOFF_MS` (default 1500 ms) intervals before giving up. + +If no events appear after all retries, the worker removes the "in progress" placeholder from the `memory` table (a row written by the SessionStart hook to reserve the slot) rather than leaving it stranded forever. + +### Step 2: check for an existing summary + +For resumed sessions, a prior summary may already exist. The worker queries the `memory` table for the session's summary row and, if found, reads the embedded `**JSONL offset**: N` marker to know how many events the previous summary already covered. This offset is passed to the gate prompt so the model can focus on events since the last checkpoint. + +### Step 3: run the gate prompt + +The worker builds a structured prompt from a template, substituting the temp JSONL path, the existing summary path, the session ID, the project name, the previous offset, and the total event count. It then shells out to the host agent's own CLI: + +```typescript +const inv = buildClaudeInvocation(cfg.claudeBin, prompt); +execFileSync(inv.file, inv.args, { + timeout: 120_000, + env: { ...process.env, HIVEMIND_WIKI_WORKER: "1", HIVEMIND_CAPTURE: "false" }, +}); +``` + +The gate CLI writes the generated markdown to a temp file (`summary.md` in the worker's temp dir). Using the host CLI means no separate API key is needed. + +### Step 4: embed and upload + +If the temp summary file exists and is non-empty, the worker embeds the text via `EmbedClient.embed(text, "document")` (returns `null` if embeddings are disabled) and uploads the summary to the `memory` table: + +``` +memory table path: /summaries//.md +``` + +The upload is an UPSERT keyed on the `path` column. The `description` column stores a short excerpt of the summary, and the `summary_embedding` column stores the vector (or `NULL`). + +After a successful upload, the sidecar is updated via `finalizeSummary(sessionId, jsonlLines)` to record the new baseline count. + +```mermaid +sequenceDiagram + participant capture as capture.ts + participant spawner as spawn-wiki-worker.ts + participant worker as wiki-worker.ts + participant deeplake as Deeplake + participant gate as claude -p (gate CLI) + participant embed as EmbedDaemon + + capture ->> spawner: maybeTriggerPeriodicSummary (threshold crossed) + spawner ->> worker: node wiki-worker.js config.json (detached) + worker ->> deeplake: SELECT events for sessionId (with retries) + deeplake -->> worker: rows[] + worker ->> deeplake: SELECT existing summary (for offset) + deeplake -->> worker: prev summary or empty + worker ->> gate: execFileSync(claudeBin, prompt) + gate -->> worker: writes summary.md to tmp dir + worker ->> embed: EmbedClient.embed(summaryText) + embed -->> worker: number[] or null + worker ->> deeplake: UPSERT /summaries//.md + worker ->> worker: finalizeSummary (update sidecar) + worker ->> worker: releaseLock (finally block) +``` + +--- + +## Error handling and resilience + +**Retries on empty events.** The five-attempt linear backoff ensures that sessions captured under heavy load (many concurrent agent sessions) still get summarized even when Deeplake read consistency lags behind the INSERT timestamps. + +**No orphan placeholders.** If events never arrive, the worker deletes the "in progress" placeholder row. The guard `AND description = 'in progress'` means a concurrent worker that already wrote a real summary is never clobbered. + +**Exponential backoff on API errors.** The worker's `query()` helper retries on HTTP 401, 403, 429, 500, 502, and 503, with exponential backoff up to 30 seconds plus jitter. Cloudflare rate-limit 403s from IP bursts can take 30-60 seconds to clear, so the jitter matters. + +**Summary embedding failures are non-fatal.** If `EmbedClient.embed()` throws, the worker logs the error, writes `NULL` for the embedding, and proceeds with the upload. A summary without an embedding is still searchable via lexical ranking. + +--- + +## Per-agent variations + +The wiki worker is bundled inside each per-agent plugin. The only variation is the gate CLI: + +| Agent | Gate CLI | +|---|---| +| claude_code | `claude -p --no-session-persistence --model ` | +| codex | `codex exec --dangerously-bypass-approvals-and-sandbox ` | +| cursor | `cursor-agent --print --model --force --output-format text ` | +| hermes | `hermes -z --provider -m --yolo --ignore-user-config` | +| pi | `pi --print --provider --model ` | + +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 worker inside their per-agent plugin bundle. + +--- + +## Configuration + +| Env var | Default | Effect | +|---|---|---| +| `HIVEMIND_SUMMARY_EVERY_N_MSGS` | `50` | Message threshold for periodic trigger | +| `HIVEMIND_SUMMARY_EVERY_HOURS` | `2` | Time threshold for periodic trigger | +| `HIVEMIND_WIKI_EVENT_RETRIES` | `5` | Retry attempts when no session events are found | +| `HIVEMIND_WIKI_EVENT_BACKOFF_MS` | `1500` | Linear backoff base for event fetch retries | +| `HIVEMIND_CURSOR_MODEL` | `auto` | (cursor) Model passed to `cursor-agent --print --model` | +| `HIVEMIND_HERMES_PROVIDER` | `openrouter` | (hermes) Provider for the gate call | +| `HIVEMIND_HERMES_MODEL` | `anthropic/claude-haiku-4-5` | (hermes) Model for the gate call | +| `HIVEMIND_PI_PROVIDER` | `google` | (pi) Provider for the gate call | +| `HIVEMIND_PI_MODEL` | `gemini-2.5-flash` | (pi) Model for the gate call | +| `HIVEMIND_CAPTURE` | `true` | Set to `false` to disable capture and summary generation | + +Worker activity logs to `~/.claude/hooks/wiki.log`. Each line shows the session being processed, the event count, the gate exit code, and the upload result. diff --git a/library/knowledge/private/architecture/session-lifecycle.md b/library/knowledge/private/architecture/session-lifecycle.md new file mode 100644 index 00000000..883accf4 --- /dev/null +++ b/library/knowledge/private/architecture/session-lifecycle.md @@ -0,0 +1,130 @@ +# Session Lifecycle + +> Category: Architecture | Version: 1.0 | Date: June 2026 | Status: Active + +The end-to-end flow of a single coding-agent session in Hivemind: from SessionStart through per-turn capture to the background summary spawned at SessionEnd, with the exact hooks that fire at each step. + +**Related:** +- [`system-overview.md`](system-overview.md) +- [`../overview.md`](../overview.md) +- [`../ai/session-capture.md`](../ai/session-capture.md) +- [`../data/memory-virtual-filesystem.md`](../data/memory-virtual-filesystem.md) +- [`../ai/wiki-summary-workers.md`](../ai/wiki-summary-workers.md) +- [`../../../../docs/CAPTURE_TASKS.md`](../../../../docs/CAPTURE_TASKS.md) +- [`../../../../docs/ARCHITECTURE.md`](../../../../docs/ARCHITECTURE.md) + +--- + +## Why the lifecycle is hook-driven + +Hivemind never runs as a daemon inside the user's session. It only ever executes as short-lived hook processes that the host assistant invokes on its own lifecycle events. That constraint shapes everything: each hook must finish fast, must fail soft (a crash exits 0 so it never breaks the user's session), and must push any heavy work to a detached background worker. The Claude Code hooks under `src/hooks/` are the reference implementation; the per-agent variants in `src/hooks/{codex,cursor,hermes,pi}/` map their assistant's event names onto the same handlers. + +A session has three phases. SessionStart authenticates and injects recall context. Each turn captures one or more events into the `sessions` table and may trigger a mid-session checkpoint. SessionEnd marks the session done and spawns the summary worker. + +--- + +## Full session sequence + +```mermaid +sequenceDiagram + participant Agent as Host assistant + participant Start as session-start.ts + participant Pre as pre-tool-use.ts + participant Cap as capture.ts + participant End as session-end.ts + participant Worker as wiki-worker.ts + participant DL as Deeplake + + Agent->>Start: SessionStart + Start->>Start: loadCredentials (device-flow login if missing) + Start->>DL: ensureTable + ensureSessionsTable + Start->>DL: createPlaceholder summary row + Start->>DL: autoPullSkills + renderContextBlock (rules/goals) + Start-->>Agent: additionalContext (memory instructions + rules + graph line) + + Agent->>Cap: UserPromptSubmit + Cap->>DL: INSERT user_message row + + Agent->>Pre: PreToolUse (Bash/Read/Grep/Glob on memory path) + Pre->>DL: route command to SQL query + Pre-->>Agent: rewritten command emitting recall results + + Agent->>Cap: PostToolUse + Cap->>DL: INSERT tool_call row + Cap->>Cap: bumpTotalCount; maybe spawn periodic worker + + Agent->>Cap: Stop + Cap->>DL: INSERT assistant_message row + Cap->>Cap: tryStopCounterTrigger (skillify) + + Agent->>End: SessionEnd + End->>End: markSessionEnded + recordSessionUsage + End->>End: forceSessionEndTrigger (skillify) + End->>Worker: spawn detached wiki worker + End-->>Agent: returns immediately + Worker->>DL: read session events + Worker->>Worker: run host CLI (claude -p / codex exec) + Worker->>DL: UPDATE summary row in memory table +``` + +--- + +## SessionStart: authenticate and inject recall + +The SessionStart hook (`src/hooks/session-start.ts`) does the setup work that makes recall possible for the rest of the session. It runs in roughly this order. + +First it re-activates the session: `clearSessionEnded`, `recordSessionOwner`, and `touchSessionActivity` (from `src/hooks/summary-state.ts`) mark the session live so other sessions' resume logic does not treat it as stale. + +Next it resolves credentials with `loadCredentials`. If there is no token, the session continues read-only and, on an unauthenticated box that has Claude Code transcripts but no mining manifest, it spawns a background `hivemind skillify mine-local` worker so the next SessionStart can surface a sign-in call to action. If a token exists, it self-heals any drifted org binding with `healDriftedOrgToken` and backfills a missing `userName`. + +It then runs `autoUpdate` before any database calls (so the upgrade notice appears even when the backend is slow), resolves the installed plugin version once for stamping on every row, and, when capture is enabled, ensures the `memory` and `sessions` tables exist and writes a placeholder summary row via direct SQL (`createPlaceholder`). Capture is gated by `HIVEMIND_CAPTURE !== "false"` and the only-CLI entrypoint check (`entrypointPassesOnlyCliGate`); under `HIVEMIND_CAPTURE=false` the hook runs fully read-only, skipping all DDL and INSERTs. + +Finally it composes the `additionalContext` payload returned to the agent. That payload is the memory-usage instructions (which describe the three VFS tiers: `index.md`, `summaries/`, and raw `sessions/`), an optional rules and goals block rendered by `renderContextBlock`, a locally-mined-skills note, and a one-line codebase-graph context line. It also fires a detached graph-pull worker whose results land for the next SessionStart, not the current one. + +--- + +## Per-turn capture + +Three event types feed the `sessions` table, all handled by `src/hooks/capture.ts`. The hook writes exactly one row per event with a single INSERT, so concurrent events never race on a shared row. + +A UserPromptSubmit event carries a `prompt` and is written as a `user_message` row. A PostToolUse event carries `tool_name`, `tool_input`, and `tool_response` and is written as a `tool_call` row. A Stop or SubagentStop event carries `last_assistant_message` and is written as an `assistant_message` row. Each row records session metadata (session id, cwd, permission mode, hook event name, agent id) and, unless embeddings are disabled, a `message_embedding` vector produced by the local embed daemon. If the INSERT fails because the table is missing (the session-start ensure failed, or the org switched mid-session), the hook creates the table and retries once. + +Capture is guarded the same way as SessionStart: it returns immediately if `HIVEMIND_CAPTURE=false`, if the plugin is disabled, or if the only-CLI entrypoint gate fails. On any fatal error it exits 0 and surfaces nothing into the agent's prompt, because writing user-facing text into `additionalContext` would be a prompt-injection pattern; user-facing notices go through the SessionStart banner channel instead. + +--- + +## Recall through the virtual filesystem + +When the agent reads memory, it issues ordinary shell or Read-tool commands against `~/.deeplake/memory/`. The PreToolUse hook (`src/hooks/pre-tool-use.ts`) intercepts Bash, Read, Grep, and Glob whose paths touch that mount and rewrites them into SQL-backed responses. `cat` becomes a direct row read, `grep` becomes a hybrid lexical-plus-semantic search through `handleGrepDirect`, `ls` becomes a path-prefix listing, and `find` becomes a path-pattern query. The rewritten command emits the fetched content with a safe `printf`, so from the agent's perspective it ran a normal command and got file output back. + +The same hook enforces the memory mount's safety contract. Write and Edit on a memory path are denied with guidance to use Bash instead, because the hook can only mutate `tool_input`, not the tool itself. Unsupported commands (interpreters, pipes the VFS cannot model, command substitution) are never handed to the host shell; they are rewritten to a harmless `echo` carrying retry guidance, so a command like `python3 ~/.deeplake/memory/../../etc/passwd` can never reach the real filesystem. PreToolUse also arms the skill-optimization counter when the agent invokes an org skill. + +--- + +## Mid-session checkpoints + +Long sessions do not wait for SessionEnd to be summarized. After writing each event, `capture.ts` calls `maybeTriggerPeriodicSummary`, which bumps a per-session event counter (`bumpTotalCount`) and checks the configured threshold (`HIVEMIND_SKILLIFY_EVERY_N_TURNS` and the time-based interval). When the threshold is crossed and the per-session lock is free (`tryAcquireLock`), it spawns a detached wiki worker with reason `Periodic`. The lock ensures only one summary worker runs per session at a time, because two workers writing the same summary row trip the Deeplake UPDATE-coalescing quirk and drop a write. + +On a Stop event, `capture.ts` additionally calls `tryStopCounterTrigger` from `src/skillify/triggers.ts`, which advances the skill-mining counter and may fire the skillify miner independently of the summary worker. + +--- + +## SessionEnd: mark done and spawn the summary + +The SessionEnd hook (`src/hooks/session-end.ts`) exits fast and pushes all heavy work to a detached process. It first calls `markSessionEnded` so other sessions stop treating this one as live, then `recordSessionUsage`, which parses the transcript for memory-search activity and appends one record to `~/.deeplake/usage-stats.jsonl` for the savings recap. This runs independently of the summary lock, so even sessions that cannot summarize still feed the recap. + +It then calls `forceSessionEndTrigger` to run skill mining (skillify has its own per-project lock, so it fires regardless of the summary lock), acquires the per-session summary lock, and spawns the wiki worker with reason `SessionEnd`. If the spawn throws before the worker takes ownership, the hook releases the lock so a `--resume` can retrigger summaries without waiting for the stale-lock reclaim. + +--- + +## The background summary worker + +The wiki worker is spawned detached by `src/hooks/spawn-wiki-worker.ts`, which writes a temp config file (API credentials, table names, session id, project, the host CLI binary, and the summary prompt template) and launches `wiki-worker.js`. The worker reads the session's events from the `sessions` table and shells out to the host agent's own CLI (`claude -p`, `codex exec`, `pi --print`) to generate a structured wiki entry, using the host CLI so no separate API key is required. The prompt template (`WIKI_PROMPT_TEMPLATE`) instructs the model to extract entities, decisions, files modified, open questions, and a single concrete "Next Steps" line, and to keep the body under 4000 characters with no absolute filesystem paths. Resumed sessions pass a JSONL offset so the worker merges new content into the existing summary rather than regenerating it. The finished summary is written back to the `memory` table alongside its 768-dimension embedding. + +--- + +## Explicit save and resume + +Beyond automatic capture, Hivemind supports an explicit save-and-resume pair documented in `docs/CAPTURE_TASKS.md`. When a user parks a side task mid-session ("save this for later"), the agent, which already holds the live context, writes a goal row whose body is a resumable context package via `hivemind goal add --agent capture`. The `agent: "capture"` provenance keeps parked tasks separable from hand-made goals. When the user later says "let's work on that task," the agent finds the goal, pulls the full body with `hivemind goal get `, flips it to `in_progress`, and continues from the stored "Start here" line with no re-explaining. The v1 design is deliberately explicit and in-session; the auto-detection pipeline (a Stop-hook LLM gate that proposes captures) is preserved as a later phase. + +For where these rows live and how the tables are shaped, see [`system-overview.md`](system-overview.md). diff --git a/library/knowledge/private/architecture/system-overview.md b/library/knowledge/private/architecture/system-overview.md new file mode 100644 index 00000000..64a53c59 --- /dev/null +++ b/library/knowledge/private/architecture/system-overview.md @@ -0,0 +1,141 @@ +# System Overview + +> Category: Architecture | Version: 1.0 | Date: June 2026 | Status: Active + +How Hivemind is laid out as a monorepo, the major subsystems, and how a shared core fans out into six per-agent integrations backed by a single Deeplake substrate. + +**Related:** +- [`session-lifecycle.md`](session-lifecycle.md) +- [`../overview.md`](../overview.md) +- [`../plugins/integration-model.md`](../plugins/integration-model.md) +- [`../data/deeplake-tables-schema.md`](../data/deeplake-tables-schema.md) +- [`../ai/session-capture.md`](../ai/session-capture.md) +- [`../../../../docs/ARCHITECTURE.md`](../../../../docs/ARCHITECTURE.md) + +--- + +## Why the architecture looks like this + +Hivemind has to live inside six different coding assistants that share almost nothing at the integration layer. Claude Code wants a marketplace plugin; Codex and Cursor want a `hooks.json`; OpenClaw wants a native extension; Hermes wants shell hooks plus an MCP server; pi wants a TypeScript extension and an `AGENTS.md` block. The architecture answers that fragmentation with a single rule: write the memory logic once, then wrap it per agent. Everything durable and agent-agnostic lives in `src/`, and each assistant gets a thin shim that maps its native lifecycle events onto the same capture and recall calls. + +That choice keeps the surface area honest. Adding a new assistant means writing a new shim, not a new memory engine. Fixing a capture bug means editing the shared core, and every agent inherits the fix on its next build. + +--- + +## Monorepo structure + +The repository separates the shared core from per-agent plugin sources and their build outputs, as described in `docs/ARCHITECTURE.md`. + +``` +hivemind/ +├── src/ ← shared core (API client, auth, config, SQL utils) +│ ├── hooks/ ← Claude Code hooks (the reference implementation) +│ ├── hooks/codex/ ← Codex hooks +│ ├── hooks/cursor/ ← Cursor hooks +│ ├── hooks/hermes/ ← Hermes shell hooks +│ ├── hooks/pi/ ← pi wiki-worker (extension in harnesses/pi/extension-source/) +│ ├── embeddings/ ← nomic embed-daemon + protocol + SQL helpers +│ ├── mcp/ ← MCP server (Hermes today; any MCP-aware client later) +│ ├── commands/ ← auth, auth-creds, auth-login, session-prune +│ └── 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/hermes/ ← Hermes plugin build output (npm-distributed) +├── mcp/ ← MCP server build output +├── harnesses/openclaw/ ← OpenClaw plugin source + build output (ClawHub) +├── harnesses/pi/ ← pi extension source (ships raw .ts; pi compiles at load) +└── 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/`, `harnesses/cursor/bundle/`, `harnesses/openclaw/dist/`, `mcp/bundle/`, and `bundle/cli.js`. + +--- + +## Major subsystems + +```mermaid +flowchart TB + subgraph agents["Host assistants"] + claudeCode["Claude Code"] + codex["Codex"] + cursor["Cursor"] + openclaw["OpenClaw"] + hermes["Hermes"] + pi["pi"] + end + + subgraph shims["Per-agent integration shims"] + marketplacePlugin["Marketplace plugin"] + hooksJson["hooks.json hooks"] + nativeExt["Native extension"] + shellHooks["Shell hooks + MCP"] + piExt["pi extension + AGENTS.md"] + end + + subgraph core["Shared core (src/)"] + captureCore["Capture handlers"] + recallCore["Recall + VFS intercept"] + wikiWorker["Wiki summary worker"] + skillify["Skillify miner"] + graph["Codebase graph"] + apiClient["Deeplake API client"] + end + + subgraph deeplake["Deeplake substrate"] + sessionsTable["sessions table"] + memoryTable["memory table + VFS"] + skillsTable["skills table"] + rulesGoals["rules / goals / kpis"] + codebaseTable["codebase table"] + end + + agents --> shims + shims --> captureCore + shims --> recallCore + captureCore --> apiClient + recallCore --> apiClient + wikiWorker --> apiClient + skillify --> apiClient + graph --> apiClient + apiClient --> deeplake +``` + +**Capture.** Every prompt, tool call, and assistant response becomes one row in the `sessions` table. The Claude Code reference handler is `src/hooks/capture.ts`, which writes a single INSERT per event and never concatenates, sidestepping write races. + +**Recall and the VFS.** Agents read memory by running shell commands against `~/.deeplake/memory/`. The PreToolUse hook (`src/hooks/pre-tool-use.ts`) intercepts those commands and rewrites them into SQL queries against the `sessions` and `memory` tables. From the agent's view it is `cat` and `grep` on files; underneath it is a team-shared database with hybrid lexical and semantic search. + +**Summarization.** At session end and on periodic checkpoints, a detached background worker (`src/hooks/wiki-worker.ts`, spawned by `src/hooks/spawn-wiki-worker.ts`) shells out to the host agent's own CLI to write a structured wiki summary into the `memory` table. Using the host CLI means no separate API key is needed. + +**Skillify.** An async miner (`src/skillify/`) reads recent in-scope sessions, asks a gate model whether the activity is worth keeping, and writes a `SKILL.md` that propagates to teammates' agents. + +**Graph.** The codebase graph subsystem (`src/graph/`) builds a live graph of files, symbols, and imports from the same traces, so recall walks the structures agents actually touched rather than plain text. + +**Deeplake API client.** `src/deeplake-api.ts` is the single chokepoint to storage. It owns table creation, lazy schema healing, and query execution; `src/deeplake-schema.ts` is the single source of truth for every column. + +--- + +## Integration model per agent + +Each assistant wires the same logical events through a different mechanism, as documented in `docs/ARCHITECTURE.md`. + +| Agent | Mechanism | Lifecycle events wired | +|---|---|---| +| 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 `hivemind_search` / `read` / `index` tools | +| Cursor (1.7+) | `~/.cursor/hooks.json` | sessionStart, beforeSubmitPrompt, postToolUse, afterAgentResponse, stop, sessionEnd | +| Hermes | Skill at `~/.hermes/skills/hivemind-memory/` plus shell hooks and MCP server | recall via grep on `~/.deeplake/memory/` | +| pi | `~/.pi/agent/AGENTS.md` block plus extension | recall via grep on `~/.deeplake/memory/` | + +The differences are real but shallow: event names and payload field names vary, so each shim normalizes its input into the shared `HookInput` shape before handing off to the core. Codex, for example, is deliberately excluded from the SessionStart rules-injection block to keep its TUI clean, while pi and OpenClaw fall back to an on-demand `hivemind context` call. + +--- + +## State and storage + +All durable state lives in Deeplake tables defined in `src/deeplake-schema.ts`. The `sessions` table holds raw per-event traces with an optional `message_embedding` vector. The `memory` table holds wiki summaries plus the virtual filesystem entries and their `summary_embedding`. Separate tables back skills, rules, goals, KPIs, and the codebase graph. Rules, skills, goals, and KPIs all use the same immutable, version-bumped write pattern (every edit INSERTs version N+1 and reads take the highest version) to sidestep a Deeplake UPDATE-coalescing quirk that previously dropped concurrent writes. + +Tenant isolation is enforced at the storage layer, not just the API: org and workspace boundaries mean sessions never share a row, partition, or index across workspaces. Credentials live on disk with mode `0600` and the config directory with mode `0700`, and the device-flow login keeps tokens out of the environment and out of source. + +For the per-event flow that produces these rows, see [`session-lifecycle.md`](session-lifecycle.md). diff --git a/library/knowledge/private/auth/auth-architecture.md b/library/knowledge/private/auth/auth-architecture.md new file mode 100644 index 00000000..738b02b3 --- /dev/null +++ b/library/knowledge/private/auth/auth-architecture.md @@ -0,0 +1,146 @@ +# Auth Architecture + +> Category: Auth | Version: 1.0 | Date: June 2026 | Status: Active + +Explains how Hivemind authenticates users and manages multi-org/workspace state: the OAuth 2.0 Device Authorization Flow, JWT-based org selection, long-lived API token minting, and the drift-repair mechanism that keeps credentials consistent across org switches. + +**Related:** +- [`../security/credential-storage.md`](../security/credential-storage.md) +- [`../security/trust-boundaries.md`](../security/trust-boundaries.md) +- [`../multi-tenant/org-workspace-model.md`](../multi-tenant/org-workspace-model.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../operations/cli-command-architecture.md`](../operations/cli-command-architecture.md) +- [`../overview.md`](../overview.md) + +--- + +## Why this exists + +Hivemind runs inside coding agents (Claude Code, Codex, Cursor, etc.) as a plugin or hook. It must authenticate without prompting for a password inside a non-interactive terminal. The OAuth 2.0 Device Authorization Flow (RFC 8628) solves this: the user visits a browser URL on any device while the CLI polls for an approval signal. No password ever passes through the plugin process. + +Once authenticated, every subsequent API call carries a long-lived, org-bound JWT that the Deeplake backend validates. The credentials are persisted locally so re-authentication is rare (one year by default). + +--- + +## Device Authorization Flow + +The flow lives in `src/commands/auth.ts` and is initiated by `login()` -> `deviceFlowLogin()`. + +```mermaid +sequenceDiagram + autonumber + participant cli as Hivemind CLI + participant api as Deeplake API + participant browser as User Browser + + cli->>api: POST /auth/device/code + api-->>cli: device_code, user_code, verification_uri, expires_in, interval + cli->>browser: open verification_uri_complete (OS open/xdg-open/start) + Note over browser: User visits URL, enters user_code, approves + loop Poll every max(interval, 5) seconds + cli->>api: POST /auth/device/token { device_code } + alt authorization_pending + api-->>cli: 400 { error: "authorization_pending" } + else approved + api-->>cli: 200 { access_token, expires_in } + else expired + api-->>cli: 400 { error: "expired_token" } + Note over cli: throw - user must restart + end + end + cli->>api: POST /users/me/tokens { name, duration: 365d, organization_id } + api-->>cli: long-lived org-bound JWT + cli->>cli: saveCredentials to ~/.deeplake/credentials.json (mode 0600) +``` + +The short-lived `access_token` from step 5 is used only for the `/users/me/tokens` mint (step 6). It is never written to disk. The persisted credential is always the org-bound 365-day token produced in step 6. + +**Token naming**: the `name` field sent to `/users/me/tokens` follows the pattern `deeplake-plugin-` (for initial login) or `deeplake-plugin-switch-` (for org switches). Deeplake's backend rejects duplicate `(user_id, name)` pairs with a misleading 500, so org-switch names include millisecond resolution to avoid collisions on the same calendar day. + +--- + +## Org Selection Priority + +After obtaining a token, `saveCredentialsFromToken()` resolves which organization to bind the credential to. The priority order is intentional and documented: + +| Priority | Source | When used | +|---|---|---| +| 1st | `HIVEMIND_ORG_ID` env var | Explicit override; always wins | +| 2nd | `org_id` JWT claim (only for `skipTokenMint=true` paths) | API token pasted via `--token` or `HIVEMIND_TOKEN` | +| 3rd | `orgs[0]` from `GET /organizations` | Device flow fallback; overridden by the upcoming mint | + +For multi-org accounts on the device flow path, `orgs[0]` is selected and then a token is minted bound to that org. The user can switch later with `hivemind org switch`. For the `--token` path, priority 2 extracts the `org_id` claim so a pre-minted API key routes to the correct org without any user interaction. + +--- + +## JWT Decoding + +`decodeJwtPayload(token: string)` extracts the payload from any JWT without cryptographic verification: + +``` +parts = token.split(".") // header.payload.signature +payload = base64url_decode(parts[1]) +return JSON.parse(payload) // Record +``` + +The decode is intentionally verify-free. It is used only to read the `org_id` claim for routing decisions (not for access control). Signature verification happens on the Deeplake API server for every authenticated request. The JWT never bypasses the server-side gate. + +The `org_id` claim carries the organization the token was minted against. This is the single source of truth used by `healDriftedOrgToken` to detect credential drift. + +--- + +## Org and Workspace Switching + +### Org switch + +`switchOrg(orgId, orgName)` remints a new org-bound token before updating the credentials file. The sequence: + +1. Call `POST /users/me/tokens` with `organization_id: target_orgId` using the current token. +2. Resolve workspace carry-over: check whether the currently-active `workspaceId` exists in the target org. If not found, reset to `"default"`. +3. Call `saveCredentials` with the new token, orgId, orgName, and resolved workspaceId. + +This ensures `creds.orgId` and `creds.token`'s `org_id` JWT claim are always aligned after a switch. + +### Workspace switch + +`switchWorkspace(workspaceId)` is simpler: it updates only `creds.workspaceId` in the local credentials file. No new token is minted because workspace context is passed to Deeplake via the `X-Activeloop-Org-Id` header per request, not baked into the JWT. + +--- + +## Drift Healing + +A legacy regression caused `org switch` to rewrite only `orgId` without reminting the token. Any session started with such drifted credentials would have `creds.orgId != jwt(token).org_id`. + +`healDriftedOrgToken(creds)` runs at session start and detects this condition: + +```mermaid +flowchart TD + start([Session Start]) --> decode[Decode JWT from creds.token] + decode --> compare{jwt.org_id == creds.orgId?} + compare -- yes --> noop([Return unchanged]) + compare -- no --> remint[POST /users/me/tokens with creds.orgId] + remint --> realignName[GET /organizations - match orgName] + realignName --> realignWs{workspaceId == default?} + realignWs -- yes --> save([saveCredentials + return healed]) + realignWs -- no --> checkWs[GET /workspaces for creds.orgId] + checkWs --> found{workspace found?} + found -- yes --> save + found -- no --> resetWs[set workspaceId = default] + resetWs --> save +``` + +The heal is best-effort: token mint failure logs a warning but does not block the session. Both the `orgName` realign and the `workspaceId` realign run in independent try/catch blocks so a transient failure on one does not skip the other. + +--- + +## Environment-Variable and Token-Flag Paths + +The `--token ` CLI flag and the `HIVEMIND_TOKEN` environment variable bypass the device flow entirely. Both paths call `saveCredentialsFromToken(token, apiUrl, { skipTokenMint: true })`. The token is assumed to be long-lived and org-bound already. The org is resolved from the `org_id` JWT claim (priority 2 from Section 3). + +Headless / CI installs use this path. The token is never echoed to stdout; it is written directly to `~/.deeplake/credentials.json`. + +--- + +## Auth Log Routing + +By default all auth messages go to `process.stderr` (safe for hook contexts, which may parse stdout). When `auth-login.ts` runs as a direct CLI command, it overrides `authLog` to `console.log` so messages surface to the terminal user. This separation ensures hooks never accidentally pollute a structured JSON stdout stream with login UI text. diff --git a/library/knowledge/private/collaboration/team-skills-sharing.md b/library/knowledge/private/collaboration/team-skills-sharing.md new file mode 100644 index 00000000..3c730300 --- /dev/null +++ b/library/knowledge/private/collaboration/team-skills-sharing.md @@ -0,0 +1,168 @@ +# Team Skills Sharing + +> Category: Collaboration | Version: 1.0 | Date: June 2026 | Status: Active + +How Hivemind mines reusable skills from agent sessions, publishes them to the org's shared Deeplake table, and automatically distributes them to every teammate's agents on the next session start. + +**Related:** +- [`../ai/skillify-pipeline.md`](../ai/skillify-pipeline.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../multi-tenant/org-workspace-model.md`](../multi-tenant/org-workspace-model.md) +- [`../frontend/cursor-extension-architecture.md`](../frontend/cursor-extension-architecture.md) +- [`../data/deeplake-tables-schema.md`](../data/deeplake-tables-schema.md) +- [`../../../../docs/SKILLIFY.md`](../../../../docs/SKILLIFY.md) + +--- + +## The core loop + +Team skills sharing is a four-step cycle that runs automatically in the background whenever Hivemind is installed: + +1. **Mine.** At session end (and periodically mid-session), a background worker reads recent session rows from Deeplake, asks a gate model whether the activity is worth codifying, and writes a `SKILL.md` to the local `.claude/skills/` directory. +2. **Publish.** Mined skills are inserted into the org's shared `skills` Deeplake table as versioned rows. Every edit appends a new version (`v=N+1`); readers always take `ORDER BY version DESC`. +3. **Pull.** On every `SessionStart`, the auto-pull module queries the `skills` table for all users in the org and writes any newer remote skills to the local install root. +4. **Propagate.** Fan-out symlinks point every non-Claude agent's skills root (`~/.codex/skills/`, `~/.hermes/skills/`, etc.) at the canonical `~/.claude/skills/--/` directory, so a pulled skill is immediately available to all agents without running a separate install command. + +--- + +## Scope configuration + +The skillify worker respects a scope setting persisted in `~/.deeplake/state/skillify/config.json`: + +```typescript +type Scope = "me" | "team"; +type InstallLocation = "project" | "global"; + +interface ScopeConfig { + scope: Scope; + team: string[]; // Deeplake usernames to mine from, when scope = "team" + install: InstallLocation; +} +``` + +The default scope is `"me"` with `install = "project"`, meaning the worker mines only from the current user's sessions and writes skills into `/.claude/skills/`. + +Setting scope to `"team"` and populating `team` with colleagues' usernames tells the worker to mine from those users' sessions as well. The CLI command is `hivemind skillify scope team --users alice,bob`. A legacy third value `"org"` (mine from every workspace user) was removed from the CLI but is silently coerced to `"team"` on read for backward compatibility with existing config files. + +Setting `install = "global"` writes skills to `~/.claude/skills/`, making them visible across all projects on the machine. The auto-pull always uses `install = "global"` so pulled teammate skills are available everywhere. + +--- + +## Auto-pull at session start + +The auto-pull runs on every `SessionStart` hook for every agent that Hivemind supports. It is intentionally not throttled: because `runPull` is idempotent (skipping any skill whose local version is at-or-newer than the remote version), the only cost per call is one SQL round-trip plus `existsSync` syscalls. This makes teammate-mined skills visible within seconds of publication, rather than within the 30-minute polling window an older design used. + +The auto-pull is bounded by a 5-second timeout. A slow or unreachable Deeplake backend never blocks `SessionStart` past that limit. All errors are swallowed; the pull result is informational only. + +Hard opt-out is available via `HIVEMIND_AUTOPULL_DISABLED=1`. Unauthenticated sessions skip the pull silently without logging a warning. + +### Early exit when table is absent + +On a fresh workspace, the `skills` table does not exist yet (it is created lazily by the first `INSERT`). The auto-pull uses a "trusted table list" path to detect this: it calls `api.knownTablesOrNull()` to fetch the list of existing tables, and if `skills` is absent, skips the `SELECT` entirely. This prevents a `42P01 relation does not exist` error from appearing in the Deeplake server logs on every `SessionStart` for new users. + +--- + +## Skill directory layout + +Pulled skills land on disk with a `--` directory name under the install root: + +``` +~/.claude/skills/ + deploy--alice/ + SKILL.md (pulled from alice; version 3) + deploy/ + SKILL.md (locally mined by current user; version 2) + test-setup--bob/ + SKILL.md (pulled from bob) +``` + +The `--` suffix serves three purposes: Claude Code's skill loader scans one directory deep and sees all skills without a separate discovery step; cross-author name collisions stay disjoint on disk (two people can both author a `deploy` skill without clobbering each other); and the directory name self-documents provenance at a glance. + +A locally-mined skill for the same name as a pulled skill coexists cleanly: the locally-mined copy lives at `deploy/` while the pulled copy lives at `deploy--alice/`. When the local user's worker mines a skill that improves on their own copy, it writes to `deploy/` and does not disturb `deploy--alice/`. + +Skills with an empty `author` field in the `skills` table are skipped during pull: writing them to `//` would silently clobber the user's locally-mined slot for that skill name, breaking the coexistence guarantee. + +--- + +## Fan-out symlinks + +When a skill is pulled for a global install, the pull engine creates symlinks in every detected non-Claude agent skills root so the pulled skill is immediately visible without separate install commands: + +``` +~/.codex/skills/deploy--alice -> ~/.claude/skills/deploy--alice/ +~/.hermes/skills/deploy--alice -> ~/.claude/skills/deploy--alice/ +~/.pi/skills/deploy--alice -> ~/.claude/skills/deploy--alice/ +``` + +Detected agent roots are discovered by `detectAgentSkillsRoots`, which checks for the presence of known agent directories under the user's home directory. Fan-out is only done for global installs; project-local pulls (`/.claude/skills/`) are not fanned out. + +The fan-out is idempotent: re-running the same pull with the same set of detected roots is a no-op for links that already point at the correct canonical path. Stale links (pointing at a different canonical path, for example after `HOME` was moved) are unlinked and recreated. + +### Backfill on agent install + +A user who installs a new agent (Codex, Hermes, pi) after having already pulled skills would find their existing pulled skills invisible to the new agent because the per-row fan-out only fires on rows whose action is `"wrote"`. Up-to-date skills take the `"skipped"` path, which never runs fan-out. + +`backfillSymlinks` closes this gap: at the end of every pull run (except dry-runs and project-local pulls), it scans the manifest for all globally-installed entries and ensures each has a symlink in every currently-detected agent root. The cost is roughly one `lstat` syscall per (entry, detected root) pair. For a typical fleet of 50 skills and 3 agent roots that is about 150 syscalls, negligible compared to the SQL round-trip. + +--- + +## Version conflict handling + +`decideAction` determines what to do when a remote skill exists locally: + +| Condition | Action | +|---|---| +| Local file absent | Write | +| Remote version > local version | Backup existing to `SKILL.md.bak`, then write | +| Remote version <= local version (no `--force`) | Skip | +| `--force` flag set | Backup existing, then write regardless of version | + +The `--force` flag is available via `hivemind skillify pull --force`. Dry-run mode (`--dry-run`) reports what would have been written without touching the filesystem. + +--- + +## Cross-author merge and scope promotion + +When the skillify worker discovers a remote skill from a different author and proposes merging it with the user's own copy, the resulting merged skill is published with scope `"team"` and the original author plus `"skillopt"` marker appended to the `contributors` array. This is the cross-author merge path introduced to handle the ambiguous-lineage case where two users independently build the same capability. + +The `SKILLOPT_CONTRIBUTOR = "skillopt"` marker is stamped on every skill that the SkillOpt improvement loop touches, so provenance always records when the automated loop contributed to a skill's evolution, distinct from a human contributor. + +--- + +## Manifest tracking + +Every pull writes a record to the local pull manifest so `hivemind skillify unpull` can identify and reverse pull-managed entries without relying on the `--` directory naming heuristic. The manifest records the `dirName`, `name`, `author`, `projectKey`, `remoteVersion`, `install`, `installRoot`, `pulledAt`, and the list of symlinks created by fan-out. + +When `recordPull` fails (for example due to a transient write error), the skill is still on disk but the manifest does not have an entry. The `manifestError` field in the pull result entry surfaces this condition so the CLI can warn the user. A subsequent successful re-pull will populate the manifest entry. + +--- + +## Mermaid: end-to-end skill lifecycle + +```mermaid +flowchart TD + session["Agent session ends"] + worker["Skillify worker spawned (background)"] + gate["Gate model: worth codifying?"] + noSkill["No skill written"] + writeLocal["Write SKILL.md to .claude/skills//"] + insertRow["INSERT into skills table (Deeplake)"] + nextSession["Teammate's SessionStart"] + autoPull["autoPullSkills (5s timeout)"] + selectSkills["SELECT from skills table WHERE version > local"] + writeTeammate["Write ~/.claude/skills/--/SKILL.md"] + fanout["Fan-out symlinks to codex/hermes/pi roots"] + agentSees["Agent sees new skill in context"] + + session --> worker + worker --> gate + gate -- no --> noSkill + gate -- yes --> writeLocal + writeLocal --> insertRow + insertRow --> nextSession + nextSession --> autoPull + autoPull --> selectSkills + selectSkills --> writeTeammate + writeTeammate --> fanout + fanout --> agentSees +``` diff --git a/library/knowledge/private/data/codebase-graph.md b/library/knowledge/private/data/codebase-graph.md new file mode 100644 index 00000000..ca45927c --- /dev/null +++ b/library/knowledge/private/data/codebase-graph.md @@ -0,0 +1,191 @@ +# Codebase Graph + +> Category: Data | Version: 1.0 | Date: June 2026 | Status: Active + +How Hivemind builds a live graph of files, symbols, and edges from source code: the discover-extract-snapshot build pipeline, the tree-sitter extractors for nine languages, cross-file resolution, content-addressed caching, deterministic snapshot hashing, cloud push and pull through the `codebase` table, and the synthesized `graph/` query surface agents read. + +**Related:** +- [`deeplake-tables-schema.md`](deeplake-tables-schema.md) +- [`memory-virtual-filesystem.md`](memory-virtual-filesystem.md) +- [`../ai/embeddings-retrieval.md`](../ai/embeddings-retrieval.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../architecture/session-lifecycle.md`](../architecture/session-lifecycle.md) +- [`../overview.md`](../overview.md) + +--- + +## Why a code graph + +Recall over raw conversation traces tells an agent what was discussed; a code graph tells it how the code is actually wired. The graph subsystem (`src/graph/`) extracts files, symbols, and relationships directly from source so an agent can ask "who calls this function", "what is the blast radius of changing this symbol", or "walk me through this subsystem" and get answers grounded in the current checkout rather than in prose. + +The output deliberately mirrors the NetworkX node-link JSON format (a directed multigraph) so any tool that already understands NetworkX graphs can consume a snapshot. The feature is AST-only: it uses tree-sitter parsers, never an LSP, a type checker, or an LLM, which keeps builds fast and deterministic. Nine languages are supported: TypeScript, JavaScript, Python, Go, Rust, Java, Ruby, C, and C++. + +--- + +## The build pipeline + +`hivemind graph build` walks the repo, extracts every supported source file, aggregates the results into one snapshot, and writes it to disk under `~/.hivemind/graphs//`. + +```mermaid +flowchart TD + build["hivemind graph build"] --> discover["discoverSourceFiles"] + discover --> gitls["git ls-files (honors .gitignore)"] + discover --> walk["fallback: manual walk"] + gitls --> perFile["per file"] + walk --> perFile + perFile --> cacheCheck{"content-hash cached?"} + cacheCheck -->|hit| reuse["reuse FileExtraction"] + cacheCheck -->|miss| extract["extractFile (tree-sitter)"] + extract --> writeCacheStep["writeCache by content sha256"] + reuse --> aggregate["buildSnapshot"] + writeCacheStep --> aggregate + aggregate --> resolve["cross-file calls + imports + heritage"] + resolve --> degrees["annotateNodeDegrees"] + degrees --> sortStep["sort nodes + edges (canonical)"] + sortStep --> writeStep["writeSnapshot (atomic)"] + writeStep --> push["pushSnapshot (best-effort cloud sync)"] +``` + +Source discovery prefers git's own ignore engine: `git ls-files --cached --others --exclude-standard -z` lists tracked plus untracked-not-ignored files, honoring `.gitignore` exactly (anchoring and nested rules included). A user-editable ignore set (`~/.deeplake/graph-ignore.json`) is applied as a safety net for directories the repo happens to track. When git is unavailable (a loose source directory), discovery falls back to a manual recursive walk that skips dotfiles and ignored directory names. Source files are recognized by extension; `.d.ts` declarations are excluded because they carry no implementation. + +Each file is content-hashed and looked up in the per-repo cache before extraction. The repo key is derived from the normalized git remote URL, so the same project resolves to the same storage directory across checkouts. + +--- + +## Extraction: per-file, language-routed + +`extractFile` routes a file to the language-appropriate extractor by extension. Every extractor produces the same `FileExtraction` shape, which keeps the snapshot builder and the cross-file passes language-agnostic. + +```typescript +export function extractFile(sourceCode: string, relativePath: string): FileExtraction { + const lower = relativePath.toLowerCase(); + if (isPythonPath(lower)) return extractPython(sourceCode, relativePath); + if (/\.[cm]?jsx?$/.test(lower)) return extractJavaScript(sourceCode, relativePath); + if (lower.endsWith(".go")) return extractGo(sourceCode, relativePath); + if (lower.endsWith(".rs")) return extractRust(sourceCode, relativePath); + if (lower.endsWith(".java")) return extractJava(sourceCode, relativePath); + if (lower.endsWith(".rb")) return extractRuby(sourceCode, relativePath); + if (/\.(cpp|cc|cxx|hpp)$/.test(lower)) return extractCpp(sourceCode, relativePath); + if (/\.[ch]$/.test(lower)) return extractC(sourceCode, relativePath); + return extractTypeScript(sourceCode, relativePath); +} +``` + +A `FileExtraction` carries the nodes and edges found in that file, any tree-sitter parse errors (so a malformed file is reported and skipped rather than silently lost), and two optional cross-file inputs the TypeScript extractor populates: `raw_calls` (call sites that could not be resolved within the file) and `import_bindings` (the file's imports, each tagged named, default, or namespace, with a `type_only` flag). + +--- + +## The node and edge model + +A node represents one code construct. Its `id` is globally unique within a snapshot, formatted `::`, and a module node uses `::module`. + +| Node field | Meaning | +|---|---| +| `id` | Unique key, `::` | +| `label` | Display name | +| `kind` | `function`, `class`, `method`, `interface`, `type_alias`, `enum`, `const`, `variable`, or `module` | +| `source_file` | Repo-relative path, forward slashes | +| `source_location` | `L` or `L-` | +| `language` | One of the nine supported languages | +| `exported` | Whether the symbol is exported | +| `signature`, `doc` | Intrinsic AST metadata (optional) | +| `fan_in`, `fan_out`, `is_entrypoint` | Derived after resolution (optional) | + +Edges are directed and typed. The `relation` is one of `imports`, `calls`, `extends`, `implements`, or `method_of`, and each edge carries a `confidence` of `EXTRACTED`, `INFERRED`, or `AMBIGUOUS` (current edges are almost entirely `EXTRACTED` because they are concrete AST facts). An optional `ord` disambiguates multigraph edges that share the same source, target, and relation (a function calling another twice). + +```mermaid +flowchart LR + moduleNode["file::module"] -->|imports| exportedFn["b.ts:foo:function"] + callerFn["a.ts:bar:function"] -->|calls| exportedFn + subclass["a.ts:Child:class"] -->|extends| baseClass["b.ts:Base:class"] + classNode["a.ts:Svc:class"] -->|method_of| methodNode["a.ts:Svc.run:method"] +``` + +--- + +## Cross-file resolution + +After every file is extracted, `buildSnapshot` runs three resolution passes that turn per-file placeholders into real cross-file edges. Resolution is high-confidence only; ambiguous cases are dropped, not guessed. + +The calls pass (`resolveCrossFileCalls`) matches each unresolved `raw_call` against the file's import bindings and the global export index. It emits an edge only for a named import (including `as` aliases) whose matching export exists in a resolvable local file, or a namespace call `ns.foo()` where `ns` is `import * as ns from "./local"` and the local file exports `foo`. Default imports, bare (npm) specifiers, tsconfig path aliases, barrel re-exports, instance dispatch, and dynamic `import()` are deliberately skipped. + +The imports pass (`repointImportEdges`) repoints an `imports` edge from a placeholder `external:` to the real module node when the specifier is relative and resolves to a known repo file; bare and unresolvable specifiers keep their `external:` target so "our code versus a dependency" stays distinguishable. The heritage pass (`resolveHeritageEdges`) resolves `extends` and `implements` placeholders to a same-file declaration or a named-import cross-file base type. + +Module resolution (`resolveModule`) tries the common TS suffixes in a deterministic order (the explicit extension first, then the importer's own family, then the other), and falls through to `index` files. Python files route to `resolvePythonModule`, which handles dot-relative imports by climbing package levels and dotted-absolute imports by anchoring on a unique path suffix; an ambiguous suffix match is dropped. + +Once edges are fully resolved, `annotateNodeDegrees` sets `fan_in`, `fan_out`, and `is_entrypoint` (`exported && fan_in === 0`) from the complete edge set, so degrees reflect cross-file relationships rather than just intra-file ones. + +--- + +## Snapshots: deterministic and content-addressed + +A snapshot is canonicalized before it is hashed or written. `buildSnapshot` sorts nodes by `id` and edges by `(source, target, relation, ord)`, and `canonicalJSON` serializes with object keys sorted at every nesting level and no inserted whitespace. The same code therefore always serializes to the same bytes. + +The content hash covers only the stable fields: + +```typescript +export function computeSnapshotSha256(snapshot: GraphSnapshot): string { + const stable = { + directed: snapshot.directed, + multigraph: snapshot.multigraph, + graph: snapshot.graph, + nodes: snapshot.nodes, + links: snapshot.links, + }; + return createHash("sha256").update(canonicalJSON(stable)).digest("hex"); +} +``` + +The `observation` field (timestamp, branch, worktree path, generator version, file counts) is deliberately excluded so two builds of identical code on different worktrees, branches, or at different times produce the same `snapshot_sha256` and dedup correctly. Any new field that is volatile must go into `observation`, never into `graph`, or this hash silently breaks dedup. + +`writeSnapshot` writes atomically (temp file plus `renameSync` in the same directory, so a crash leaves either the old file or the new one, never a partial). The snapshot lands at `/snapshots/.json`, or `.json` when there is no commit context. Per-worktree singletons (`latest-commit.txt` and `.last-build.json`) live under `worktrees//` so two checkouts of the same repo on one machine do not clobber each other's metadata, while snapshots, the cache, and `history.jsonl` stay shared at the repo level. The worktree id is a sha256 of the absolute worktree path, truncated to 16 characters. + +--- + +## Caching: content-addressed, self-healing + +The per-file cache turns a full rebuild from seconds into tens of milliseconds when only one file changed. Its key is the sha256 of the file content, not the path, so identical content across files, branches, or users shares one entry. + +``` +~/.hivemind/graphs//.cache/.json +``` + +Because the cache is content-addressed, invalidation is automatic: different content yields a different key, so a stale read is impossible. A `CACHE_SCHEMA_VERSION` embedded in each entry lets an extractor-output change invalidate old entries wholesale, since readers ignore mismatched-schema entries and fall through to re-extraction. On a cache hit after a rename or copy, `readCache` rewrites every `source_file` field, every edge id prefix, and every module node label to the caller's current path, so a reused entry never leaks the original path back into the snapshot. Corrupt entries fail validation and fall through to a fresh extraction that overwrites them. + +--- + +## Cloud sync: push and pull + +A successful build pushes the snapshot to the `codebase` table (see [`deeplake-tables-schema.md`](deeplake-tables-schema.md)) when the user is authenticated. Push is best-effort: the local snapshot is the source of truth, and any failure logs without blocking the build. Push is skipped silently when there is no auth, no commit context, or `HIVEMIND_GRAPH_PUSH=0`. + +`pushSnapshot` uses SELECT-before-INSERT with drift detection: it selects the row for the full identity key `(org, workspace, repo, user, worktree, commit)`. If a row exists with a matching `snapshot_sha256` it is a no-op (`already-current`); if it exists with a different hash it logs a `drift` warning and refuses to overwrite, because the same commit producing different content means extractor-version drift that a human should investigate. With no existing row it inserts, storing the canonical bytes in `snapshot_jsonb`. Because the identity key has no server-side UNIQUE constraint, the function re-selects after insert and reports `inserted-with-duplicate-race` if more than one row is found, making the race observable rather than silent; the SessionEnd auto-build path also takes a cross-process build lock to serialize the most common concurrent caller. + +`pullSnapshot` answers the opposite question: the freshest snapshot of the current HEAD for this user, from any worktree. It relaxes the identity key to drop `worktree_id` and takes `ORDER BY ts DESC LIMIT 1`, because identical source content extracts to identical bytes regardless of which checkout produced it. Before writing anything to disk it validates the payload shape and recomputes the stable-field hash, refusing a payload whose hash does not match the claimed `snapshot_sha256` so a corrupt row never poisons the local cache. It also gates the local-newer comparison on the local build referring to the same commit, so checking out an older commit correctly pulls rather than wrongly reporting "local newer". + +--- + +## The query surface + +Agents read the graph through the synthesized `graph/` subtree of the memory mount (the bridge is described in [`memory-virtual-filesystem.md`](memory-virtual-filesystem.md)). `handleGraphVfs` reads only the local snapshot and renders text on the fly: + +| Endpoint | Returns | +|---|---| +| `index.md` | Overview: commit, node and edge counts, node and edge kind breakdowns, top files, limitations | +| `find/` | Case-insensitive substring search on node id and label, numbered handles, fuzzy fallback on no match | +| `query/` | The 2-in-1: find plus a 1-hop neighbor expansion of the top matches grouped by relation | +| `show/` | Full node detail plus incoming and outgoing edges grouped by relation | +| `impact/` | Transitive dependents (blast radius) of a symbol | +| `neighborhood/` | Symbols in a file plus their cross-file neighbors | +| `layers` | Architectural subsystem grouping by path heuristic | +| `tour` | Deterministic dependency-ordered walkthrough | +| `path//` | Shortest path between two symbol patterns | + +Search ranks exact label over prefix over id-contains over label-contains, tie-broken by id. A single token with no substring hit falls back to a bounded zero-dependency Levenshtein fuzzy match (typo tolerance like `pushSnaphot` to `pushSnapshot`). `find/` persists numbered handles per worktree in `.find-handles.json` so a follow-up `show/` resolves the right node, and `show/` re-validates that the handle still points at a node present in the current snapshot. + +The renderers carry an honest caveat: cross-file `calls` are resolved only for relative named and namespace imports, so a node reading "Incoming (0)" is not proof of dead code (a caller may reach it through an unresolved import path), and a snapshot whose source files have been edited since the build is stale and should be cross-checked against live source. + +--- + +## Inspecting history + +Beyond the live query surface, the CLI exposes the build record. `hivemind graph diff ` loads two snapshots by commit and prints added and removed node and edge counts with examples. `hivemind graph history` tails the per-repo `history.jsonl`, an append-only audit log where each entry is self-describing (its own commit, hash, counts, and trigger), which is why entries from different worktrees can interleave safely. `hivemind graph init` installs a managed post-commit hook that rebuilds after each commit, and `hivemind graph pull` fetches a teammate's cloud snapshot for the current HEAD. Together these keep the on-disk graph current with minimal manual intervention while the local snapshot remains the authoritative source for every read. diff --git a/library/knowledge/private/data/deeplake-tables-schema.md b/library/knowledge/private/data/deeplake-tables-schema.md new file mode 100644 index 00000000..3b49f7c9 --- /dev/null +++ b/library/knowledge/private/data/deeplake-tables-schema.md @@ -0,0 +1,322 @@ +# Deeplake Tables Schema + +> Category: Data | Version: 1.0 | Date: June 2026 | Status: Active + +The canonical reference for every Deeplake table Hivemind owns: the full column DDL, the two write patterns that sidestep Deeplake's UPDATE quirk, the lazy schema-healing primitive, and the SQL-escaping rules that stand in for parameterized queries. + +**Related:** +- [`memory-virtual-filesystem.md`](memory-virtual-filesystem.md) +- [`codebase-graph.md`](codebase-graph.md) +- [`../auth/auth-architecture.md`](../auth/auth-architecture.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../architecture/session-lifecycle.md`](../architecture/session-lifecycle.md) +- [`../overview.md`](../overview.md) + +--- + +## Why the schema is defined in one place + +Every durable byte Hivemind stores lives in a Deeplake table, and `src/deeplake-schema.ts` is the single source of truth for what those tables look like. Each table is described as an array of `{ name, sql }` column definitions, and both the `CREATE TABLE` path and the lazy schema-healing path iterate over that same array. Adding a column means one edit in one file; there is no second mirror in an `ensure` or `ALTER` routine that could drift out of sync. + +This matters because Deeplake tables are created lazily, on first write, by whichever hook process happens to run first. A long-lived API client and a short-lived capture worker can both try to create or extend the same table concurrently. Centralizing the column list lets both paths heal toward the same target schema deterministically, regardless of who wins the race. + +Two cross-cutting facts shape every table below. First, Deeplake's HTTP query endpoint does not support parameterized queries, so all values are escaped and interpolated by hand (see the SQL-safety section). Second, Deeplake has an UPDATE-coalescing quirk: two rapid UPDATEs to the same row within microseconds can silently drop one. Tables that expect concurrent edits therefore use an append-only, version-bumped pattern instead of in-place UPDATE. + +--- + +## The seven tables at a glance + +Hivemind owns seven tables. Their logical relationships (Deeplake enforces no foreign keys; all joins are logical) look like this: + +```mermaid +erDiagram + sessions ||--o{ memory : "summarized into" + goals ||--o{ kpis : "goal_id" + skills }o--|| project : "project_key" + rules }o--|| org : "scope" + codebase }o--|| repo : "repo_slug" + + sessions { + text id + text path + jsonb message + float4Array message_embedding + } + memory { + text id + text path + text summary + float4Array summary_embedding + } + skills { + text id + text name + bigint version + } + rules { + text id + text rule_id + bigint version + } + goals { + text id + text goal_id + text status + } + kpis { + text id + text goal_id + text kpi_id + } + codebase { + text commit_sha + text snapshot_sha256 + text snapshot_jsonb + } +``` + +| Table | Purpose | Write pattern | +|---|---|---| +| `sessions` | Raw per-turn agent events (one row per event) | Append-only INSERT | +| `memory` | Wiki summaries and VFS file rows | UPDATE-or-INSERT keyed by `path` | +| `skills` | Mined `SKILL.md` versions | Append-only, version-bumped | +| `rules` | Org-wide principles | Append-only, version-bumped | +| `goals` | User-tracked objectives | UPDATE-or-INSERT keyed by `goal_id` | +| `kpis` | Metrics attached to a goal | UPDATE-or-INSERT keyed by `(goal_id, kpi_id)` | +| `codebase` | Code-graph snapshots | SELECT-before-INSERT, per identity key | + +--- + +## Memory and sessions: the capture and recall substrate + +The `memory` table holds wiki summaries written by the SessionStart and SessionEnd workers, plus every file the virtual filesystem materializes. Its `summary` column is the file body; `summary_embedding` is the optional 768-dimension nomic vector that powers semantic recall. + +```sql +CREATE TABLE IF NOT EXISTS "memory" ( + id TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '', + filename TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + summary_embedding FLOAT4[], + author TEXT NOT NULL DEFAULT '', + mime_type TEXT NOT NULL DEFAULT 'text/plain', + size_bytes BIGINT NOT NULL DEFAULT 0, + project TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + agent TEXT NOT NULL DEFAULT '', + plugin_version TEXT NOT NULL DEFAULT '', + creation_date TEXT NOT NULL DEFAULT '', + last_update_date TEXT NOT NULL DEFAULT '' +) USING deeplake; +``` + +The `sessions` table holds the raw event stream: one row per UserPromptSubmit, PostToolUse, Stop, or SubagentStop event. Its `message` column is `JSONB` rather than `TEXT` because each row carries a structured payload (prompt, tool input, tool response). A single conversation produces many rows, so readers concatenate by `path` ordered by `creation_date`. + +```sql +CREATE TABLE IF NOT EXISTS "sessions" ( + id TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '', + filename TEXT NOT NULL DEFAULT '', + message JSONB, + message_embedding FLOAT4[], + author TEXT NOT NULL DEFAULT '', + mime_type TEXT NOT NULL DEFAULT 'application/json', + size_bytes BIGINT NOT NULL DEFAULT 0, + project TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + agent TEXT NOT NULL DEFAULT '', + plugin_version TEXT NOT NULL DEFAULT '', + creation_date TEXT NOT NULL DEFAULT '', + last_update_date TEXT NOT NULL DEFAULT '' +) USING deeplake; +``` + +The capture path INSERTs a single row per event and never concatenates into an existing row, which is the deliberate fix for the write-race the wiki worker once hit. How these two tables are surfaced as a browsable filesystem is the subject of [`memory-virtual-filesystem.md`](memory-virtual-filesystem.md). + +--- + +## Skills and rules: append-only version history + +Skills and rules both expect edits over time and both want an audit trail, so they share the same shape: every write INSERTs a fresh row with `version` bumped by one, and readers take the highest version per logical key (`ORDER BY version DESC LIMIT 1`). This is the explicit countermeasure to the UPDATE-coalescing quirk. Two rapid edits become two distinct rows rather than two UPDATEs that fight over one row. + +```sql +CREATE TABLE IF NOT EXISTS "skills" ( + id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + project TEXT NOT NULL DEFAULT '', + project_key TEXT NOT NULL DEFAULT '', + local_path TEXT NOT NULL DEFAULT '', + install TEXT NOT NULL DEFAULT 'project', + source_sessions TEXT NOT NULL DEFAULT '[]', + source_agent TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'me', + author TEXT NOT NULL DEFAULT '', + contributors TEXT NOT NULL DEFAULT '[]', + description TEXT NOT NULL DEFAULT '', + trigger_text TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + version BIGINT NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '' +) USING deeplake; +``` + +The current state for a `(project_key, name)` pair is the most recent row. The `source_sessions` and `contributors` columns store JSON-encoded arrays as text; readers parse them back and fall back to `[author]` when `contributors` is an empty array (a legacy-caller shape that still round-trips). + +```sql +CREATE TABLE IF NOT EXISTS "rules" ( + id TEXT NOT NULL DEFAULT '', + rule_id TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'team', + status TEXT NOT NULL DEFAULT 'active', + assigned_by TEXT NOT NULL DEFAULT '', + version BIGINT NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT '', + agent TEXT NOT NULL DEFAULT 'manual', + plugin_version TEXT NOT NULL DEFAULT '' +) USING deeplake; +``` + +A rule edit INSERTs version+1; the latest per `rule_id` wins. Rules feed the SessionStart context block alongside goals. + +--- + +## Goals and KPIs: path-encoded, UPDATE-or-INSERT + +Goals and KPIs are backed by the virtual filesystem path conventions, and the path is the source of truth for their structural fields. A goal lives at `memory/goal///.md`; a KPI lives at `memory/kpi//.md`. The `content` column stores only the human-readable markdown body, so there is nothing to drift between the path-encoded fields and the row body. + +```sql +CREATE TABLE IF NOT EXISTS "goals" ( + id TEXT NOT NULL DEFAULT '', + goal_id TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'opened', + content TEXT NOT NULL DEFAULT '', + version BIGINT NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '', + agent TEXT NOT NULL DEFAULT 'manual', + plugin_version TEXT NOT NULL DEFAULT '' +) USING deeplake; +``` + +Unlike skills and rules, the goals and KPIs tables hold one row per logical key forever. A status transition, an owner reassignment, or a body edit mutates the same row in place via UPDATE rather than inserting a new version. The `version` column survives as a vestigial `1`, kept so the audit-trail pattern can be reinstated without a migration. This is a deliberate v1 trade-off: one row per goal makes the Deeplake table view obvious and bootstrap queries simple, at the cost of no audit trail and exposure to the UPDATE-coalescing quirk for two writes that hit the same row within microseconds. For the single-user and small-team workflow this was an accepted choice. + +The status enum is `opened`, `in_progress`, or `closed`, mirroring the path folder names. The `created_at` timestamp is preserved across edits (a status change records its time in `updated_at`) so goals stay in stable creation order in listings. + +```sql +CREATE TABLE IF NOT EXISTS "kpis" ( + id TEXT NOT NULL DEFAULT '', + goal_id TEXT NOT NULL DEFAULT '', + kpi_id TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + version BIGINT NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '', + agent TEXT NOT NULL DEFAULT 'manual', + plugin_version TEXT NOT NULL DEFAULT '' +) USING deeplake; +``` + +A KPI is keyed by `(goal_id, kpi_id)`. Owner is intentionally not stored on the KPI; it is derived from the parent goal by a logical join on `goal_id`, which avoids a multi-file cascade move whenever a goal is reassigned between owners. The body is free markdown, by convention carrying `target:`, `current:`, and `unit:` lines that the commit-extract worker mutates. + +How these path conventions are parsed and dispatched is detailed in [`memory-virtual-filesystem.md`](memory-virtual-filesystem.md). + +--- + +## Codebase: snapshot rows for the code graph + +The `codebase` table stores one row per `(org, workspace, repo, user, worktree, commit)` identity. The `snapshot_jsonb` column holds the canonical NetworkX node-link JSON written to disk, and `snapshot_sha256` both dedups identical content and detects extractor-version drift, because the same commit with the same extractor should always produce the same hash. + +```sql +CREATE TABLE IF NOT EXISTS "codebase" ( + org_id TEXT NOT NULL DEFAULT '', + workspace_id TEXT NOT NULL DEFAULT '', + repo_slug TEXT NOT NULL DEFAULT '', + user_id TEXT NOT NULL DEFAULT '', + worktree_id TEXT NOT NULL DEFAULT '', + commit_sha TEXT NOT NULL DEFAULT '', + parent_sha TEXT NOT NULL DEFAULT '', + branch TEXT NOT NULL DEFAULT '', + ts TIMESTAMP, + pushed_by TEXT NOT NULL DEFAULT '', + snapshot_sha256 TEXT NOT NULL DEFAULT '', + snapshot_jsonb TEXT NOT NULL DEFAULT '', + node_count BIGINT NOT NULL DEFAULT 0, + edge_count BIGINT NOT NULL DEFAULT 0, + generator TEXT NOT NULL DEFAULT 'hivemind-graph', + generator_version TEXT NOT NULL DEFAULT '', + schema_version BIGINT NOT NULL DEFAULT 1 +) USING deeplake; +``` + +The identity key has no server-side UNIQUE constraint in the current schema, so the push path uses a SELECT-before-INSERT pattern and re-verifies after insert to make any concurrent-writer race observable. The full build, push, and pull lifecycle is covered in [`codebase-graph.md`](codebase-graph.md). + +--- + +## SQL safety: escaping in place of parameters + +Because the Deeplake query endpoint does not bind parameters, `src/utils/sql.ts` provides three escaping helpers that every query builder must use before interpolating a value. + +```typescript +export function sqlStr(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/'/g, "''") + .replace(/\0/g, "") + .replace(/[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); +} +``` + +`sqlStr` escapes a value for a single-quoted literal: it doubles backslashes and single quotes, drops NUL bytes, and strips control characters. `sqlLike` layers on `%` and `_` escaping for use inside `LIKE`/`ILIKE` patterns. `sqlIdent` validates a table or column name against `^[a-zA-Z_][a-zA-Z0-9_]*$` and throws on anything else, so identifiers are never interpolated unchecked. + +```typescript +export function sqlIdent(name: string): string { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + throw new Error(`Invalid SQL identifier: ${JSON.stringify(name)}`); + } + return name; +} +``` + +Text bodies that may contain escape sequences are written with the `E'...'` string form so the doubled-backslash escaping round-trips correctly; this is why the VFS flush path emits `summary = E'${text}'` rather than a plain literal. + +--- + +## Schema healing: converging on the target columns + +When a write fails because the table or a column is missing, the writer does not blindly recreate everything. It runs a targeted heal pass: one `SELECT` against `information_schema.columns` reads the current column set, the result is diffed against the schema definition, and only the genuinely missing columns are added with `ALTER TABLE ADD COLUMN`. The pass never blanket-ALTERs and never uses `IF NOT EXISTS`; the single tolerated race ("already exists" from a concurrent writer) is caught and re-verified with a second SELECT. + +```mermaid +flowchart TD + insertAttempt["INSERT attempt"] --> failed{"failed?"} + failed -->|no| done["row written"] + failed -->|missing table| createTable["CREATE TABLE IF NOT EXISTS"] + createTable --> healPass["healMissingColumns"] + failed -->|missing column| healPass + healPass --> selectCols["SELECT information_schema.columns"] + selectCols --> diff["diff against schema"] + diff --> alter["ALTER ADD only missing"] + alter --> retry["retry INSERT once"] + retry --> done + failed -->|other| rethrow["rethrow original error"] +``` + +A guard at module load (`validateSchema`) rejects any `NOT NULL` column that lacks a `DEFAULT`, because `ALTER TABLE ADD COLUMN ... NOT NULL` on a populated table fails without a value to backfill. This catches a missing default at startup rather than in production healing. The `healMissingColumns` result returns both `missing` (what the diff found) and `altered` (what this call actually ran), which lets a short-lived worker distinguish "schema was already current" from "the ALTER lost every race", and decide whether the original error came from a column outside the schema's knowledge (in which case it rethrows rather than looping on a hopeless retry). + +Error classification lives in the same module. `isMissingTableError` matches the "relation does not exist" family while explicitly excluding permission errors and any message mentioning `column` (which routes to the column branch instead). `isMissingColumnError` matches the missing-column wording. Both return false for permission-denied messages so a credentials problem never gets misread as a schema gap. + +--- + +## Reading the current state + +The read patterns follow directly from the write patterns: + +- `memory`: read the row for a `path` directly (`SELECT summary FROM "memory" WHERE path = '...'`). +- `sessions`: read all rows for a `path` ordered by `creation_date` and concatenate the messages. +- `skills` and `rules`: take the highest `version` per logical key. +- `goals` and `kpis`: read the single row per key, ordered by `created_at DESC` at bootstrap. +- `codebase`: SELECT by the identity key; the pull path relaxes the key to drop `worktree_id` and takes `ORDER BY ts DESC LIMIT 1` for the freshest snapshot of a commit. + +These conventions keep every table internally consistent under concurrent hook processes without relying on database transactions, which Deeplake does not expose at this layer. diff --git a/library/knowledge/private/data/memory-virtual-filesystem.md b/library/knowledge/private/data/memory-virtual-filesystem.md new file mode 100644 index 00000000..671f4c10 --- /dev/null +++ b/library/knowledge/private/data/memory-virtual-filesystem.md @@ -0,0 +1,182 @@ +# Memory Virtual Filesystem + +> Category: Data | Version: 1.0 | Date: June 2026 | Status: Active + +How Hivemind makes a team-shared Deeplake database look like an ordinary directory at `~/.deeplake/memory/`: the `DeeplakeFs` intercept, path-routed dispatch to the goals and KPIs tables, batched writes with debounced flush, the synthesized `index.md`, and the read-only sessions and graph bridges. + +**Related:** +- [`deeplake-tables-schema.md`](deeplake-tables-schema.md) +- [`codebase-graph.md`](codebase-graph.md) +- [`../security/trust-boundaries.md`](../security/trust-boundaries.md) +- [`../architecture/session-lifecycle.md`](../architecture/session-lifecycle.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../ai/embeddings-retrieval.md`](../ai/embeddings-retrieval.md) +- [`../overview.md`](../overview.md) + +--- + +## Why a filesystem over a database + +Coding agents already know how to `cat`, `ls`, `grep`, and `find`. Hivemind leans on that fluency: instead of teaching every assistant a new recall API, it presents memory as files under `~/.deeplake/memory/` and intercepts the shell commands that touch that mount. From the agent's point of view it is browsing files; underneath, each operation is a SQL query against the `sessions`, `memory`, `goals`, and `kpis` tables described in [`deeplake-tables-schema.md`](deeplake-tables-schema.md). + +There are two consumers of this intercept. The PreToolUse hook rewrites Claude Code Bash, Read, Grep, and Glob commands one-shot and stateless. The standalone deeplake-shell exposes the same mount through a long-lived `DeeplakeFs` object that implements the `IFileSystem` interface from `just-bash`. Both produce the same view; this document focuses on the `DeeplakeFs` implementation in `src/shell/deeplake-fs.ts`, which is the richer of the two. + +The mount is not a literal directory. No real files exist at these paths. Every read either hits an in-memory cache, a pending-write buffer, or a SQL query, and every write is buffered and flushed to Deeplake on a timer. + +--- + +## Anatomy of DeeplakeFs + +`DeeplakeFs` keeps four maps that together model the tree: + +- `files`: path to `Buffer` (content) or `null` (the row exists but its body has not been fetched yet). +- `meta`: path to size, mime type, and mtime. +- `dirs`: directory path to the set of immediate child names. +- `pending`: paths written but not yet flushed to SQL. + +A `flushed` set tracks which paths have already been INSERTed at least once, so a later flush of the same path uses UPDATE rather than a second INSERT. The constructor seeds the tree with the mount point and its parent. + +At construction the factory `create()` bootstraps four sources in parallel before returning, so `ls` and `cat` work immediately against the cache: + +```mermaid +flowchart TD + create["DeeplakeFs.create()"] --> ensure["ensureTable + ensureGoalsTable + ensureKpisTable"] + ensure --> parallel["Promise.all bootstrap"] + parallel --> mem["memory rows: SELECT path, size_bytes, mime_type"] + parallel --> sess["sessions rows: GROUP BY path, MAX(size_bytes)"] + parallel --> goals["goals rows: latest per goal_id"] + parallel --> kpis["kpis rows: latest per goal_id, kpi_id"] + mem --> tree["populate files/meta/dirs maps"] + sess --> tree + goals --> tree + kpis --> tree +``` + +The memory bootstrap reads `path, size_bytes, mime_type` ordered by path and registers each row as an unfetched file (`files.set(p, null)`). Crucially, it skips any goal-shaped or KPI-shaped path when the dedicated tables are configured, because those rows belong exclusively to the structured tables. Surfacing the generic-table copies would re-inject phantom goals into the VFS namespace that the `hivemind goal list` CLI (which reads only the structured table) would not see. + +The sessions bootstrap groups by `path` and takes `MAX(size_bytes)`, a workaround for a Deeplake behavior where `SUM(size_bytes)` returns NULL when combined with `GROUP BY path`. For the single-row-per-file layout MAX equals SUM; for multi-row layouts it under-reports but stays positive so files never look like empty placeholders. + +--- + +## Path classification: three destinations + +Every read and write is first classified by `classifyPath` (from `src/shell/goal-paths.ts`) into one of three kinds: + +| Kind | Path shape | Backing table | +|---|---|---| +| `goal` | `memory/goal///.md` | `goals` | +| `kpi` | `memory/kpi//.md` | `kpis` | +| `memory` | anything else | `memory` | + +The classifier strips any leading mount prefix by finding the last `/memory/` occurrence in the path, which lets it accept every shape an agent might produce: a mount-relative `/goal/...`, a test mount `/memory/goal/...`, a shell redirect `~/.deeplake/memory/goal/...`, or a host-absolute `/home//.deeplake/memory/goal/...`. The status component must be one of `opened`, `in_progress`, or `closed`, and the filename must end in `.md`; anything malformed falls back to `memory` so the generic path handles it. + +```typescript +export function classifyPath(p: string): PathKind { + const segs = segmentsUnderMemory(p); + if (!segs) return "memory"; + if (segs[0] === "goal") { + if (segs.length === 4 && segs[3].endsWith(".md") && VALID_STATUS.has(segs[2])) { + return "goal"; + } + return "memory"; + } + if (segs[0] === "kpi") { + if (segs.length === 3 && segs[2].endsWith(".md")) return "kpi"; + return "memory"; + } + return "memory"; +} +``` + +The path encoding is the source of truth: `decomposeGoalPath` extracts `owner`, `status`, and `goal_id` from the path, and the row's `content` column stores only the markdown body. `composeGoalPath` and `composeKpiPath` rebuild the canonical mount-relative path (no mount prefix) that both the cache and the DB rows use. + +--- + +## Writes: batch, debounce, flush + +Writes do not hit SQL immediately. `writeFile` updates the in-memory cache and tree, then enqueues a `PendingRow` and either flushes right away (when `pending.size` reaches the batch size of 10) or schedules a debounced flush 200 ms out. This coalesces the bursty write pattern of an agent editing several files in quick succession into a handful of round-trips. + +```mermaid +sequenceDiagram + participant Agent + participant Fs as DeeplakeFs + participant Embed as embed daemon + participant DL as Deeplake + + Agent->>Fs: writeFile(path, content) + Fs->>Fs: update files/meta/dirs, enqueue PendingRow + alt pending >= 10 + Fs->>Fs: flush() now + else + Fs->>Fs: scheduleFlush (200ms debounce) + end + Fs->>Embed: computeEmbeddings(rows) + Embed-->>Fs: vectors (or null when disabled) + Fs->>DL: upsertRow per row (INSERT or UPDATE) + DL-->>Fs: results + Fs->>Fs: re-queue any rejected rows +``` + +The flush is serialized through a promise chain (`flushChain`) so two flushes never interleave. `_doFlush` drains the pending map, computes embeddings for the batch (skipping the daemon hop entirely when embeddings are globally disabled, writing NULL for the vector columns), and upserts every row in parallel via `Promise.allSettled`. Any row that fails is re-queued for the next flush unless a newer version was written in the meantime, and the flush throws so callers know some writes were deferred. + +`upsertRow` dispatches by path kind. Goal and KPI writes route to `upsertGoalRow` / `upsertKpiRow`, which do their own SELECT-then-UPDATE-or-INSERT keyed by `goal_id` (or `goal_id, kpi_id`). The generic memory path branches on the `flushed` set: a path already flushed gets an UPDATE of `summary`, `summary_embedding`, `mime_type`, `size_bytes`, and `last_update_date` (plus optional `project` and `description`); a fresh path gets a full INSERT with a new UUID. Text bodies are escaped with `sqlStr` and written with the `E'...'` literal form (see [`deeplake-tables-schema.md`](deeplake-tables-schema.md)). + +`appendFile` takes a fast path that avoids a read-back: when the file already exists it issues a SQL-level concatenation (`summary = summary || E'...'`) and invalidates the content cache so the next read fetches fresh data. This makes append O(1) per call rather than read-modify-write. + +--- + +## Reads: cache, pending, sessions, SQL + +`readFile` resolves content through a fixed precedence. It first checks the graph VFS bridge (covered below), then the synthesized `index.md`, then the content cache, then the pending-write buffer, then the sessions concatenation, and finally a direct SQL read of the `summary` column. + +Session files are special. A session path lives in the `sessions` table as many rows (one per turn), so a read concatenates them: `SELECT message FROM "" WHERE path = '...' ORDER BY creation_date ASC`, normalized and joined with newlines. Session files are read-only at the VFS layer; `writeFile`, `appendFile`, `rm`, `cp`, and `mv` all reject session paths with `EPERM`, because they are an append-only event log owned by the capture hook. + +The `index.md` at the mount root is virtual. If no real row exists for `/index.md`, `generateVirtualIndex` builds one on the fly. It fetches the 50 most-recent summary rows (one extra beyond the cap to detect "more available") and the 50 most-recent session rows grouped by path, then hands both to the pure renderer `buildVirtualIndexContent` in `src/hooks/virtual-table-query.ts`. That renderer is the single source of truth shared by the deeplake-shell and the stateless PreToolUse hook path; it emits a two-section markdown table (memory summaries and raw sessions) with a per-section truncation notice pointing the agent at Grep for older rows. + +`prefetch` warms the cache for many paths with one query each for the memory and sessions tables, batched at 50 paths per `IN (...)` clause, so a directory walk does not fan out into one query per file. + +--- + +## Goal lifecycle through filesystem verbs + +The goals table is mutated entirely through filesystem operations, with `rm` and `mv` carrying special meaning rather than their literal POSIX semantics. + +`rm` on a goal path is a soft-close, not a delete. It writes the goal's content to the canonical `closed/.md` path (status flipped to `closed`) via `upsertGoalRow`, moves the cache entry from the source folder to the closed folder, and removes the old tree entry so a subsequent `ls` of `opened/` or `in_progress/` reflects the absence. The audit trail is preserved: the row still exists, just with `status = 'closed'`. `rm` on an already-closed goal is a no-op for the same reason, so an agent cannot accidentally wipe history. + +`mv` between two goal paths is a status transition. It enforces the invariant that only the status component may change: the `goal_id` and `owner` must match, or the operation fails with `EPERM`. This avoids the cp-then-rm dance that would otherwise double-write to the goals table. + +```mermaid +stateDiagram-v2 + [*] --> opened : writeFile goal//opened/.md + opened --> inProgress : mv to in_progress/ + inProgress --> opened : mv back to opened/ + opened --> closed : rm (soft-close) + inProgress --> closed : rm (soft-close) + closed --> closed : rm again (no-op) +``` + +Both operations preserve `created_at` and record the edit time in `updated_at`, keeping goals in stable creation order in listings. + +--- + +## The graph VFS bridge + +A subtree at `/graph/` is not backed by any table at all. It is a synthesized read-only view over the local codebase-graph snapshot, dispatched by `handleGraphVfs` in `src/graph/vfs-handler.ts`. `DeeplakeFs` detects the `/graph/` prefix before its normal cache check, strips it, and delegates. The dispatcher is pure: it reads only the local snapshot file for the shell's current working directory and makes zero network calls. + +```typescript +function readGraphFile(p: string, cwd: string): string { + const sub = graphSubpathOf(p); + const r = handleGraphVfs(sub, cwd); + if (r.kind === "ok") return r.body; + if (r.kind === "no-graph") return `(no-graph) ${r.message}`; + throw fsErr("ENOENT", `${r.message}`, p); +} +``` + +The bridge keeps the FS contract honest. The `no-graph` result (no snapshot built yet for this cwd) is rendered as the file body rather than thrown as `ENOENT`, because the path conceptually exists and is reporting its own emptiness, mirroring how `/index.md` behaves when no rows exist. The `exists`, `stat`, and `realpath` methods are aligned so that `/graph`, `/graph/find`, and `/graph/show` are always-true directories while a leaf path only exists when the dispatcher returns `ok` or `no-graph`, never for an unknown endpoint. The query surface those paths expose is documented in [`codebase-graph.md`](codebase-graph.md). + +--- + +## What the agent never sees + +The intercept hides three things the agent would otherwise trip over. It hides write batching: a `cat` immediately after a `Write` reads from the pending buffer, so the agent sees its own write even before it reaches Deeplake. It hides the multi-row session layout: a session "file" is dozens of rows concatenated transparently. And it hides the goals and KPIs structured tables behind plain markdown files, so the agent manages objectives with `Write` and `mv` while the CLI reads the same state from typed columns. The result is that recall feels like browsing a directory while every operation is really a query against a team-shared, multi-tenant database. diff --git a/library/knowledge/private/frontend/cursor-extension-architecture.md b/library/knowledge/private/frontend/cursor-extension-architecture.md new file mode 100644 index 00000000..ea0fc0d8 --- /dev/null +++ b/library/knowledge/private/frontend/cursor-extension-architecture.md @@ -0,0 +1,186 @@ +# Cursor Extension Architecture + +> Category: Frontend | Version: 1.0 | Date: June 2026 | Status: Active + +How Hivemind wires into Cursor 1.7+ via hooks.json, what each hook does, and how the session-start context block presents auth state and org identity to the agent. + +**Related:** +- [`../plugins/integration-model.md`](../plugins/integration-model.md) +- [`../plugins/hook-lifecycle.md`](../plugins/hook-lifecycle.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../architecture/session-lifecycle.md`](../architecture/session-lifecycle.md) +- [`../multi-tenant/org-workspace-model.md`](../multi-tenant/org-workspace-model.md) +- [`../collaboration/team-skills-sharing.md`](../collaboration/team-skills-sharing.md) + +--- + +## Why the Cursor integration exists + +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 `harnesses/cursor/bundle/`. + +--- + +## Hook inventory + +Cursor fires five hooks relevant to Hivemind. Each maps to one compiled Node script: + +| Cursor event | Script | What it does | +|---|---|---| +| `sessionStart` | `session-start.ts` | Recalls context, injects auth state, auto-pulls skills, spawns graph worker | +| `beforeSubmitPrompt` | `capture.ts` | Writes the user's prompt as a `user_message` row in the `sessions` table | +| `postToolUse` | `capture.ts` | Writes each tool call and its output as a `tool_call` row | +| `afterAgentResponse` | `capture.ts` | Writes the assistant's reply as an `assistant_message` row; triggers periodic summary check | +| `stop` | `capture.ts` | Writes a `stop` row with final status and loop count | +| `sessionEnd` | `session-end.ts` | Spawns final wiki-worker summary and forces skillify session-end trigger | + +The `preToolUse` hook is also wired for the `Shell` tool only. It intercepts any shell command aimed at `~/.deeplake/memory/` and rewrites it into a SQL query, returning the result as an `echo` command. This promotes Cursor from the "Tier 3 VFS file-stream" accuracy tier to "Tier 1 SQL fast-path" accuracy - the same level as Claude Code. + +--- + +## Session-start context block + +The most user-visible part of the Cursor integration is the `additional_context` string injected into every new session. Cursor passes this string directly into the agent's working context before the first user turn, so the agent sees Hivemind's memory layout and available CLI commands without any user prompt. + +The block is composed in layers inside `session-start.ts`: + +``` +base context (memory layout + CLI commands) + + auth state line ("Logged in as org: Acme (workspace: default)" OR "Not logged in. Run: hivemind login") + + goals instructions (when logged in) + + rules block (org-wide rules from hivemind_rules table, when logged in) + + graph context line (when a codebase graph exists for this workspace root) +``` + +### Auth state line + +The auth state line is the only place in the Cursor UI where org and workspace identity is surfaced to the agent. It reads directly from the credentials loaded at hook startup: + +``` +Logged in to Deeplake as org: Acme (workspace: default) +``` + +or, when credentials are absent: + +``` +Not logged in to Deeplake. Run: hivemind login +``` + +When credentials exist but carry a drifted org token (the `jwt.org_id` claim does not match `creds.orgId`), the session-start hook calls `healDriftedOrgToken` before building the context block. The heal re-mints the token against the correct org, then realigns `orgName` and validates `workspaceId` against the same org. This means the auth state line always reflects the org the user last switched to, not the org baked into a stale token. + +### Rules block + +When the user is logged in, `renderContextBlock` queries the `hivemind_rules` table and appends any active rules to the context. Rules are org-wide by default (scope `team`) and are inserted unconditionally into every agent session across the workspace. + +--- + +## Capture mechanics + +All four capture events (`beforeSubmitPrompt`, `postToolUse`, `afterAgentResponse`, `stop`) are handled by the same compiled script, `capture.ts`. Each event writes one row into the `sessions` table with an `agent` field of `"cursor"` and a `plugin_version` field stamped from the bundle's `.claude-plugin` version marker. + +The capture script respects two environment gates: + +- `HIVEMIND_CAPTURE=false`: skips all INSERT operations, making the integration fully read-only for the session. +- `isHivemindPluginEnabled()`: a marketplace-managed flag that lets users pause capture without uninstalling the plugin. + +Cursor delivers `tool_output` already JSON-encoded as a string, unlike Claude Code which delivers a structured object. The capture script handles this difference: it passes `tool_output` through without further `JSON.stringify` wrapping. + +Embeddings are computed per row when the nomic embed daemon is available. If the daemon is absent or `HIVEMIND_EMBEDDINGS=false` is set, the `message_embedding` column lands as NULL and the row is still written. The self-heal path (`ensurePluginNodeModulesLink`) runs once per process to restore any broken symlink that a marketplace auto-upgrade may have dropped. + +--- + +## Periodic summary trigger + +Every `afterAgentResponse` event bumps a per-session counter stored in `~/.deeplake/state/` (via `bumpTotalCount`). When the count crosses a configurable threshold (`everyNMessages`) or a time threshold (`everyHours`) is exceeded, the hook spawns a detached wiki-worker process to summarise the current session's activity. The worker uses `cursor-agent --print` (the Cursor agent CLI) to write the summary into the `memory` table. + +A file-system lock prevents two wiki workers from running concurrently for the same session: the periodic trigger checks `tryAcquireLock` before spawning, and the session-end hook does the same before spawning the final summary worker. If a periodic worker is already running when the session ends, the session-end hook skips the final spawn. + +--- + +## Session-end summary + +When Cursor fires `sessionEnd`, the hook: + +1. Reads `conversation_id` (or `session_id`) from the payload. +2. Calls `forceSessionEndTrigger` to fire the skillify miner for this session. +3. Checks the wiki-worker lock. If a periodic worker is mid-flight, skips the final spawn; otherwise acquires the lock and spawns the wiki worker. +4. The wiki worker runs `cursor-agent --print` against the captured session rows, writes a summary into `memory`, and releases the lock. + +--- + +## Pre-tool-use recall intercept + +The `preToolUse` hook fires only when Cursor calls the `Shell` tool. The hook checks whether the command targets `~/.deeplake/memory/` (or its aliases). If it does, the hook: + +1. Parses the bash command using the shared `parseBashGrep` parser. +2. Runs `searchDeeplakeTables` as a single SQL query against the `memory` and `sessions` tables. +3. Returns an `updated_input` that replaces the original shell command with `echo `. + +Cursor never executes the original grep against the real filesystem. From the agent's view, it ran `grep` and got back structured memory results. This is the same semantic contract that Claude Code's `PreToolUse` hook provides, making Cursor recall quality identical across both agents. + +--- + +## Auto-update + +The session-start hook calls `autoUpdate` before any database operations. This checks the installed plugin version against the published latest version and emits an upgrade notice when the two diverge. The check has no dependency on table state, so it fires promptly even when the Deeplake API is slow or unreachable. + +--- + +## Mermaid: session-start flow + +```mermaid +sequenceDiagram + participant cursor as Cursor 1.7+ + participant hook as sessionStart hook + participant auth as auth.ts + participant api as DeeplakeApi + participant autopull as autoPullSkills + + cursor->>hook: sessionStart event (JSON stdin) + hook->>auth: loadCredentials() + alt credentials present + hook->>auth: healDriftedOrgToken() + hook->>hook: autoUpdate check + hook->>api: ensureTable + ensureSessionsTable + hook->>api: createPlaceholder row + hook->>api: knownTablesOrNull (trusted table list) + hook->>api: renderContextBlock (rules + goals) + else no credentials + hook->>hook: maybeAutoMineLocal (local-only mode) + end + hook->>autopull: autoPullSkills (5s timeout) + autopull->>api: SELECT from skills table + autopull-->>hook: pulled N skills + hook->>hook: build additionalContext block + hook->>cursor: JSON { additional_context } +``` + +--- + +## File locations + +| File | Role | +|---|---| +| `src/hooks/cursor/session-start.ts` | SessionStart hook source | +| `src/hooks/cursor/capture.ts` | Multi-event capture script | +| `src/hooks/cursor/session-end.ts` | SessionEnd summary trigger | +| `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`) | +| `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 new file mode 100644 index 00000000..bef68d07 --- /dev/null +++ b/library/knowledge/private/infrastructure/monorepo-build-release.md @@ -0,0 +1,247 @@ +# Monorepo Build and Release Pipeline + +> Category: Infrastructure | Version: 1.0 | Date: June 2026 | Status: Active + +How Hivemind compiles, bundles, and packages its monorepo core and per-agent integrations. + +**Related:** +- [`../overview.md`](../overview.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../plugins/integration-model.md`](../plugins/integration-model.md) +- [`../operations/cli-command-architecture.md`](../operations/cli-command-architecture.md) +- [`../plugins/mcp-and-extension-surfaces.md`](../plugins/mcp-and-extension-surfaces.md) + +--- + +## Why this pipeline exists + +Hivemind is built as a single TypeScript monorepo that supports six different coding assistants. Each assistant requires its own specific plugin structure, files layout, and distribution format. To deliver optimal performance, reduce memory overhead, and ensure near-instant startup times inside resource-constrained IDE environments, Hivemind implements a two-stage compilation and bundling process: + +1. **Type Checking and Compilation (`tsc`):** The TypeScript compiler compiles the entire shared core and agent shims, verifying type safety and emitting modular JavaScript to the `dist/` directory. +2. **Bundling (`esbuild`):** An esbuild script gathers the compiled outputs and bundles them into self-contained, optimized, and executable modules. This step drops unused code, resolves imports, and generates independent plugin bundles for each platform. + +This approach keeps the source code maintainable in a unified monorepo while delivering custom-tailored, self-contained, and highly optimized artifacts to the respective host assistants. + +--- + +## Pipeline Execution + +The entry point of the build process is defined in the workspace `package.json` scripts: + +```33:50:package.json + "scripts": { + "prebuild": "node scripts/sync-versions.mjs", + "build": "tsc && node esbuild.config.mjs", + "bundle": "node esbuild.config.mjs", + "dev": "tsc --watch", + "shell": "tsx src/shell/deeplake-shell.ts", + "cli": "tsx src/cli/index.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "dup": "jscpd src", + "audit:openclaw": "node scripts/audit-openclaw-bundle.mjs", + "pack:check": "node scripts/pack-check.mjs", + "rebuild:native": "node scripts/ensure-tree-sitter.mjs", + "ci": "npm run typecheck && npm run dup && npm test", + "postinstall": "node scripts/ensure-tree-sitter.mjs", + "prepare": "husky && npm run build", + "prepack": "npm run build" + }, +``` + +--- + +## Version Synchronization + +To keep the release version in sync across the entire ecosystem, the `prebuild` hook runs a dedicated version sync script. This script reads the version number from the main `package.json` file and propagates it to all plugin manifest files: + +```13:25:scripts/sync-versions.mjs +const SOURCE = "package.json"; + +// Scalar targets: each has a single top-level `version` field tracking package.json. +export const SCALAR_TARGETS = [ + ".claude-plugin/plugin.json", + "harnesses/claude-code/.claude-plugin/plugin.json", + "harnesses/openclaw/openclaw.plugin.json", + "harnesses/openclaw/package.json", + "harnesses/codex/package.json", +]; +``` + +The script is idempotent. It parses the JSON manifests, verifies whether the target version matches, and performs the file write only if there is a discrepancy. It also updates the marketplace definition file: + +```63:88:scripts/sync-versions.mjs + const marketplace = readJsonAt(root, MARKETPLACE_PATH); + let mpChanged = false; + if (marketplace.metadata?.version !== version) { + const old = marketplace.metadata?.version; + marketplace.metadata = marketplace.metadata || {}; + marketplace.metadata.version = version; + log(`sync-versions: ${MARKETPLACE_PATH} metadata.version: ${old} -> ${version}`); + mpChanged = true; + } + if (Array.isArray(marketplace.plugins)) { + for (const plugin of marketplace.plugins) { + if (plugin.version !== version) { + const old = plugin.version; + plugin.version = version; + log(`sync-versions: ${MARKETPLACE_PATH} plugins[${plugin.name}].version: ${old} -> ${version}`); + mpChanged = true; + } + } + } + if (mpChanged) { + writeJsonAt(root, MARKETPLACE_PATH, marketplace); + writes++; + } else { + log(`sync-versions: ${MARKETPLACE_PATH} already at ${version}`); + skips++; + } +``` + +--- + +## Bundling with Esbuild + +The core bundling engine is `esbuild.config.mjs`. It orchestrates compilation configs for the different targets, matching their runtime requirements and dependencies. + +### Output Bundles and Targets + +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 `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`. +* **MCP Server:** Generated under `mcp/bundle/`. It builds the standalone Model Context Protocol server that can be hooked into Cline, Roo, or Kilo. +* **Unified CLI:** Generated under `bundle/`. It produces the primary `hivemind` executable binary. +* **Embed Daemon:** Generated under `embeddings/`. It compiles the standalone semantic search background daemon. + +### Handling Native Dependencies + +A major challenge in bundling Hivemind is its dependency on native Node modules. Specifically, the codebase graph extractor uses `tree-sitter` and various language grammars, which compile down to native binary files (`.node` assets). + +Because esbuild cannot bundle native binaries into a pure JavaScript module, these dependencies are declared as `external` in the esbuild configurations. At runtime, the bundles resolve these dependencies from `node_modules` on the host machine. + +```52:82:esbuild.config.mjs +await build({ + entryPoints: Object.fromEntries(ccAll.map(h => [h.out, h.entry])), + bundle: true, + platform: "node", + format: "esm", + outdir: "harnesses/claude-code/bundle", + external: [ + "node:*", + "node-liblzma", + "@mongodb-js/zstd", + "@huggingface/transformers", + "onnxruntime-node", + "onnxruntime-common", + "sharp", + // tree-sitter and language grammars ship native .node prebuilds that + // esbuild cannot bundle. Resolved from node_modules at runtime. + "tree-sitter", + "tree-sitter-typescript", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-go", + "tree-sitter-rust", + "tree-sitter-java", + "tree-sitter-ruby", + "tree-sitter-c", + "tree-sitter-cpp", + ], + define: { + __HIVEMIND_VERSION__: JSON.stringify(hivemindVersion), + }, +}); +``` + +--- + +## OpenClaw Specialized Bundling and Security + +The OpenClaw gateway operates under a strict sandbox model. This requires advanced optimizations and security overrides during compilation. + +### Stubbing Node Processes + +The shared core contains utilities that invoke sub-processes, such as opening browser tabs during login or launching background workers. In OpenClaw, these actions are disallowed or unneeded because it is a remote gateway. + +To eliminate unreachable code that would trigger security warnings on the ClawHub registry, esbuild uses a custom plugin to stub the native `node:child_process` module: + +```404:426:esbuild.config.mjs + plugins: [{ + // Dead-code elimination for transitively bundled CC/Codex-only features. + // harnesses/openclaw/src/index.ts imports shared modules from ../../src/ (DeeplakeApi, + // grep-core, virtual-table-query, auth device-flow). Several of those + // modules also host CC-specific helpers that shell out with execSync — + // opening the browser for SSO, nudging claude-plugin-update, spawning the + // wiki-worker daemon. Those helpers are never called through the openclaw + // entry point (openclaw is a pure HTTP/WebSocket gateway; it has no local + // browser, uses its own plugin installer, and does not run the wiki-worker + // daemon). Replacing node:child_process with a no-op export drops that + // dead code from the bundle instead of shipping unreachable exec calls. + name: "stub-unused-child-process", + setup(build) { + build.onResolve({ filter: /^node:child_process$/ }, () => ({ + path: "node:child_process", + namespace: "stub", + })); + build.onLoad({ filter: /.*/, namespace: "stub" }, () => ({ + contents: "export const execSync = () => {}; export const execFileSync = () => {}; export const spawn = () => {};", + loader: "js", + })); + }, + }], +``` + +### Global Environment Dispatch + +The ClawHub scanner blocks uploads containing raw `process.env` lookups alongside network queries to prevent credential harvesting. To bypass this, the esbuild configuration rewrites all environment variable lookups into properties on a global tuning object: + +```371:403:esbuild.config.mjs + // ----- User-tunable knobs: routed through a globalThis dispatch ----- + // Every read of `process.env.HIVEMIND_X` in transitively-bundled code is + // rewritten by esbuild to `globalThis.__hivemind_tuning__.HIVEMIND_X`. + // The openclaw plugin's `register()` populates that object from + // `pluginApi.pluginConfig.tuning` (i.e. what the user wrote under + // `plugins.entries.hivemind.config.tuning` in `openclaw.json`). So the + // bundle has zero `process.env.X` substrings (ClawHub scan passes), AND + // the user can still tune at runtime by editing openclaw.json + restart. + // CodeRabbit + @efenocchi on #170 pushed back on the previous + // inline-to-undefined approach because it removed the env-override + // surface entirely. This restores it via a different mechanism. + "process.env.HIVEMIND_DEBUG": "globalThis.__hivemind_tuning__.HIVEMIND_DEBUG", + "process.env.HIVEMIND_TRACE_SQL": "globalThis.__hivemind_tuning__.HIVEMIND_TRACE_SQL", + "process.env.HIVEMIND_QUERY_TIMEOUT_MS": "globalThis.__hivemind_tuning__.HIVEMIND_QUERY_TIMEOUT_MS", + "process.env.HIVEMIND_INDEX_MARKER_TTL_MS": "globalThis.__hivemind_tuning__.HIVEMIND_INDEX_MARKER_TTL_MS", + "process.env.HIVEMIND_INDEX_MARKER_DIR": "globalThis.__hivemind_tuning__.HIVEMIND_INDEX_MARKER_DIR", + "process.env.HIVEMIND_SEMANTIC_LIMIT": "globalThis.__hivemind_tuning__.HIVEMIND_SEMANTIC_LIMIT", + "process.env.HIVEMIND_HYBRID_LEXICAL_LIMIT": "globalThis.__hivemind_tuning__.HIVEMIND_HYBRID_LEXICAL_LIMIT", + "process.env.HIVEMIND_GREP_LIKE": "globalThis.__hivemind_tuning__.HIVEMIND_GREP_LIKE", + "process.env.HIVEMIND_SEMANTIC_SEARCH": "globalThis.__hivemind_tuning__.HIVEMIND_SEMANTIC_SEARCH", + "process.env.HIVEMIND_SEMANTIC_EMBED_TIMEOUT_MS": "globalThis.__hivemind_tuning__.HIVEMIND_SEMANTIC_EMBED_TIMEOUT_MS", + "process.env.HIVEMIND_SEMANTIC_EMIT_ALL": "globalThis.__hivemind_tuning__.HIVEMIND_SEMANTIC_EMIT_ALL", + // `HIVEMIND_STATE_DIR` is the test-isolation override that points + // `~/.deeplake/state/skillify` at a `mkdtempSync()` dir. OpenClaw has + // no testing surface and no reason to redirect state, so it always + // resolves to `undefined` at runtime — the call-site `?? + // homedir()/...` fallback produces the production path. The rewrite + // matters mainly to keep the ClawHub `env-harvesting` scanner happy: + // a literal `process.env.HIVEMIND_STATE_DIR` substring in the same + // file as a network send trips the critical rule even though the + // value is just a directory path. + "process.env.HIVEMIND_STATE_DIR": "globalThis.__hivemind_tuning__.HIVEMIND_STATE_DIR", +``` + +--- + +## Distribution and Post-Install Processes + +When the user runs `npm install -g @deeplake/hivemind`, the global package is laid down on their machine. + +A `postinstall` script immediately invokes `node scripts/ensure-tree-sitter.mjs`. This script checks the host OS, ensures that correct binary prebuilds for `tree-sitter` and the language grammars are download-resolved, and handles potential compilation fallback steps. + +The CLI binary is exposed via the `bin` field in `package.json`, which points to `bundle/cli.js`. This file is generated with an executable permission stamp `0755` and a standard hash-bang line pointing to the Node.js interpreter, ensuring a seamless terminal execution experience. diff --git a/library/knowledge/private/multi-tenant/org-workspace-model.md b/library/knowledge/private/multi-tenant/org-workspace-model.md new file mode 100644 index 00000000..460db108 --- /dev/null +++ b/library/knowledge/private/multi-tenant/org-workspace-model.md @@ -0,0 +1,168 @@ +# Org and Workspace Model + +> Category: Multi-tenant | Version: 1.0 | Date: June 2026 | Status: Active + +How Hivemind maps Deeplake's org and workspace hierarchy onto per-agent credential files, how the API client enforces tenant isolation, and what happens during org switches and token drift. + +**Related:** +- [`../auth/auth-architecture.md`](../auth/auth-architecture.md) +- [`../data/deeplake-tables-schema.md`](../data/deeplake-tables-schema.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../frontend/cursor-extension-architecture.md`](../frontend/cursor-extension-architecture.md) +- [`../collaboration/team-skills-sharing.md`](../collaboration/team-skills-sharing.md) +- [`../security/trust-boundaries.md`](../security/trust-boundaries.md) + +--- + +## Two-level hierarchy + +Hivemind's tenancy model follows Deeplake's native two-level structure: every user belongs to one or more **organizations**, and each organization contains one or more **workspaces**. Tables, rows, and vectors are scoped to a (org, workspace) pair. No query from one workspace ever sees a row that belongs to another. + +At runtime, the active org and workspace are carried in the credential file, not in environment state. Every hook process reads `~/.deeplake/credentials.json` at startup, extracts `orgId` and `workspaceId`, and passes them to `DeeplakeApi`. The API client sends `orgId` on every request via the `X-Activeloop-Org-Id` header. + +--- + +## Credential file layout + +The credential file lives at `~/.deeplake/credentials.json`. Its directory is created with mode `0700` and the file itself with mode `0600`, keeping tokens off shared-directory reads. The `Credentials` shape carries: + +```typescript +interface Credentials { + token: string; // long-lived org-bound API token + orgId: string; // Deeplake org UUID + orgName?: string; // display name for session banners + userName?: string; // local username stamped on every captured row + workspaceId?: string; // "default" or a specific workspace id/name + apiUrl?: string; // defaults to https://api.deeplake.ai + autoupdate?: boolean; + savedAt: string; // ISO timestamp of last write +} +``` + +`workspaceId` defaults to the string `"default"` when absent. Deeplake resolves the `"default"` sentinel server-side, so the client never needs to know the actual UUID of the default workspace. + +Every environment variable override (`HIVEMIND_TOKEN`, `HIVEMIND_ORG_ID`, `HIVEMIND_WORKSPACE_ID`) wins over the file at `loadConfig()` time, letting CI pipelines and test suites inject alternate tenants without touching the user's credentials. + +--- + +## Device-flow login + +Login follows RFC 8628 (Device Authorization Flow). The sequence is: + +1. `hivemind login` calls `requestDeviceCode` at `POST /auth/device/code`, which returns a `verification_uri_complete`, a short `user_code`, and a polling `device_code`. +2. The CLI opens the browser to `verification_uri_complete` (or prints the URL when the browser launch fails). The user approves on the Deeplake web app. +3. The CLI polls `POST /auth/device/token` at the server-mandated interval (minimum 5 seconds) until it receives an `access_token` or the device code expires. +4. `saveCredentialsFromToken` exchanges the short-lived Auth0 token for a long-lived API token bound to the selected org: it calls `POST /users/me/tokens` with `organization_id` and `duration=31536000` (one year), then persists the resulting token. + +The long-lived token carries an `org_id` claim baked in at mint time. This claim is what the drift-healing logic checks. + +Org selection during login follows a priority order: +1. `HIVEMIND_ORG_ID` env var (explicit override). +2. `org_id` claim in the JWT, when `skipTokenMint=true` (API-key path: the token was already minted against a specific org). +3. First org in the account's list (device-flow path: falls back to `orgs[0]`, then re-mints against it). + +--- + +## Org switching + +`hivemind org switch ` calls `switchOrg`. Because the API token carries an `org_id` claim baked in at mint time, switching orgs requires re-minting a new token, not just updating `orgId` in the credential file. A legacy bug (pre-fix, before the current code) only rewrote `orgId` without re-minting, leaving the token's JWT claim pointing at the old org. The current `switchOrg` always re-mints. + +Re-minting uses a timestamp suffix on the token name (`deeplake-plugin-switch-`) because Deeplake rejects duplicate `(user_id, name)` pairs with a `500`. A date-only suffix would collide if the user ran two switches on the same day. + +--- + +## Workspace switching + +`hivemind workspace ` calls `switchWorkspace`, which rewrites only `workspaceId` in the credential file. No token re-mint is required because the workspace is passed as a query scope parameter to the API, not baked into the JWT. Workspace IDs can be name strings or UUID strings; the API resolves both. + +--- + +## Token drift healing + +A historical deployment bug left some users with credential files where `orgId` had been updated by `org switch` but the token's `org_id` JWT claim still pointed at the old org. Every `SessionStart` hook calls `healDriftedOrgToken` to detect and repair this state transparently. + +The heal logic: + +1. Decodes the JWT payload from `creds.token` without verifying the signature (no public key needed: this is a read of public claims). +2. Compares `jwt.org_id` to `creds.orgId`. If they match, returns the credential unchanged. +3. On mismatch, re-mints a fresh org-bound token against `creds.orgId` using a `Date.now()` suffixed name. +4. With the new token, runs two independent best-effort realignments: + - Fetches `GET /organizations` and updates `orgName` if the display name drifted. + - Fetches `GET /workspaces` and resets `workspaceId` to `"default"` if the previously-set workspace no longer exists in the new org, or resolves a name to its canonical UUID if it was stored by name. +5. Persists the healed credentials and returns them. + +The heal never throws: a failed re-mint logs a warning and returns the original (stale) credentials so the session can continue. The two realignment blocks are independent try/catch blocks so a transient API error on one cannot suppress the other. + +--- + +## Config loading and table name resolution + +`loadConfig()` in `src/config.ts` assembles the full runtime configuration from the credential file plus environment overrides: + +| Config field | Default | Env override | +|---|---|---| +| `token` | `credentials.json:token` | `HIVEMIND_TOKEN` | +| `orgId` | `credentials.json:orgId` | `HIVEMIND_ORG_ID` | +| `workspaceId` | `"default"` | `HIVEMIND_WORKSPACE_ID` | +| `apiUrl` | `https://api.deeplake.ai` | `HIVEMIND_API_URL` | +| `tableName` (memory) | `"memory"` | `HIVEMIND_TABLE` | +| `sessionsTableName` | `"sessions"` | `HIVEMIND_SESSIONS_TABLE` | +| `skillsTableName` | `"skills"` | `HIVEMIND_SKILLS_TABLE` | +| `rulesTableName` | `"hivemind_rules"` | `HIVEMIND_RULES_TABLE` | +| `goalsTableName` | `"hivemind_goals"` | `HIVEMIND_GOALS_TABLE` | +| `kpisTableName` | `"hivemind_kpis"` | `HIVEMIND_KPIS_TABLE` | +| `codebaseTableName` | `"codebase"` | `HIVEMIND_CODEBASE_TABLE` | +| `memoryPath` | `~/.deeplake/memory` | `HIVEMIND_MEMORY_PATH` | + +Table names are scoped to the `(orgId, workspaceId)` pair by the Deeplake API itself: two workspaces that each have a table named `"memory"` hold completely separate data. The client does not prefix table names. + +--- + +## Member management + +Org membership is managed through three API functions in `src/commands/auth.ts`: + +- `inviteMember(username, accessMode, ...)` - invites a user with role `ADMIN`, `WRITE`, or `READ`. +- `listMembers(token, orgId, ...)` - returns `{ user_id, name, email, role }` for every current member. +- `removeMember(userId, ...)` - removes a member by their Deeplake user ID. + +All three pass the `X-Activeloop-Org-Id` header so they operate against the correct org. The CLI surfaces these as `hivemind invite`, `hivemind members`, and `hivemind remove`. + +--- + +## Tenant isolation at the storage layer + +Deeplake enforces org and workspace boundaries at the storage layer: tables, rows, partitions, and vector indexes are never shared across workspace boundaries. Hivemind does not implement any application-level tenant filtering (no `WHERE org_id = ?` predicates on every query). Isolation is entirely the responsibility of the API client sending the correct `X-Activeloop-Org-Id` header and using the correct workspace-scoped API endpoint. + +This means a mis-configured token (wrong `org_id` claim) does not cause data leakage to the wrong org - Deeplake returns a 403 or routes the request to the wrong org's tables. The drift-healing path described above exists precisely to prevent this routing failure from happening silently. + +--- + +## Mermaid: org and workspace resolution at session start + +```mermaid +flowchart TD + sessionStart["SessionStart hook"] + loadCreds["loadCredentials()"] + noToken{token present?} + readOnly["Read-only / unauthenticated mode"] + healDrift["healDriftedOrgToken()"] + jwtCheck{jwt.org_id === creds.orgId?} + remint["Re-mint token for creds.orgId"] + realign["Realign orgName + workspaceId"] + buildConfig["loadConfig() → assemble Config"] + apiClient["DeeplakeApi(token, orgId, workspaceId)"] + captureRecall["Capture + Recall operations"] + + sessionStart --> loadCreds + loadCreds --> noToken + noToken -- no --> readOnly + noToken -- yes --> healDrift + healDrift --> jwtCheck + jwtCheck -- match --> buildConfig + jwtCheck -- mismatch --> remint + remint --> realign + realign --> buildConfig + buildConfig --> apiClient + apiClient --> captureRecall +``` diff --git a/library/knowledge/private/operations/cli-command-architecture.md b/library/knowledge/private/operations/cli-command-architecture.md new file mode 100644 index 00000000..e3fc0441 --- /dev/null +++ b/library/knowledge/private/operations/cli-command-architecture.md @@ -0,0 +1,213 @@ +# CLI Command Architecture + +> Category: Operations | Version: 1.0 | Date: June 2026 | Status: Active + +Architecture of the Hivemind unified command-line tool, subcommand dispatching, authentication flows, and operational database commands. + +**Related:** +- [`../auth/auth-architecture.md`](../auth/auth-architecture.md) +- [`../security/credential-storage.md`](../security/credential-storage.md) +- [`../overview.md`](../overview.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`notifications-and-health.md`](notifications-and-health.md) +- [`../infrastructure/monorepo-build-release.md`](../infrastructure/monorepo-build-release.md) + +--- + +## Why this architecture exists + +Hivemind is built with a single unified command-line interface (CLI) to reduce complexity for users and developers. Rather than requiring distinct setup tools for each of the six supported coding assistants, the global `hivemind` executable handles all environments. It performs auto-detection of assistants, wires local plugin shims, synchronizes codebase graphs, and hosts secure authentication controls. + +The design relies on a split: +* **The Unified Entry Point (`src/cli/index.ts`):** Parses global CLI flags and dispatches arguments to specialized subcommands or separate scripts. +* **The Command Handlers (`src/commands/`):** Contains the actual business logic for auth sessions, rules manipulation, codebase graphs, local trace mining, and database cleanups. + +This split guarantees that CLI presentation details never entangle core storage, encryption, or synchronization logic. + +--- + +## Command Dispatching + +The unified CLI routes input arguments inside a centralized dispatcher. It recognizes standard commands and handles fallback routes, such as delegating account and organization administration tasks directly to the auth module. + +```409:445:src/cli/index.ts +async function main(): Promise { + const args = process.argv.slice(2); + const cmd = args[0]; + + if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") { + log(USAGE); + return; + } + if (cmd === "--version" || cmd === "-v" || cmd === "version") { + log(getVersion()); + return; + } + + if (cmd === "install") { await runInstallAll(args.slice(1)); return; } + if (cmd === "uninstall") { + const only = parseOnly(args.slice(1)); + const targets: PlatformId[] = only ?? detectPlatforms().map(p => p.id); + for (const id of targets) runSingleUninstall(id); + return; + } + + if (cmd === "login") { await ensureLoggedIn(); return; } + if (cmd === "status") { runStatus(); return; } + if (cmd === "update") { + const code = await runUpdate({ dryRun: hasFlag(args.slice(1), "--dry-run") }); + process.exit(code); + } + + if (cmd === "skillify") { + runSkillifyCommand(args.slice(1)); + return; + } + + if (cmd === "rules") { + await runRulesCommand(args.slice(1)); + return; + } +``` + +If a command matches one of the organization or workspace subcommands, the dispatcher forwards the complete arguments array to the auth-login router: + +```486:491:src/cli/index.ts + // Account / org / workspace subcommands — passthrough to the auth-login dispatcher. + if (AUTH_SUBCOMMANDS.has(cmd)) { + await runAuthCommand(args); + return; + } +``` + +--- + +## Authentication and Device Authorization Flow + +Hivemind relies on the RFC 8628 Device Authorization Flow to handle sign-ins securely. This enables headless installs, remote-SSH environments, and local terminals to authenticate against the Deeplake cloud without manual token generation. + +The flow operates as follows: +1. **Device Code Request:** The client calls `/auth/device/code` on the API and receives a verification URI and user code. +2. **User Authorization:** The client opens the default browser pointing to the complete URI or instructs the user to open it manually. +3. **Token Polling:** The client polls the `/auth/device/token` endpoint at the prescribed interval. If the authorization is pending, it continues; if verified, it receives a short-lived token. +4. **Credential Storage:** The token is validated against the `/me` endpoint, a preferred organization is selected (supporting overrides like `HIVEMIND_ORG_ID`), and a long-lived API token is minted through the `/users/me/tokens` endpoint. +5. **Serialization:** Credentials are written to `~/.deeplake/credentials.json` with user-private filesystem permissions (`0600`). + +### Resolving Token Drift + +A known challenge in multi-tenant SaaS environments is JWT organization drift. If a user switches organizations through the CLI, their stored active organization ID changed, but their existing org-bound JWT API token remained unchanged. This caused queries to execute against the previous tenant space or fail due to invalid claims. + +To resolve this, Hivemind implements a self-healing algorithm `healDriftedOrgToken` that automatically runs on session start. It decodes the JWT payload, compares the `org_id` claim with the active organization ID, and re-mints a corrected token if they mismatch: + +```217:240:src/commands/auth.ts +export async function healDriftedOrgToken( + creds: Credentials, + log: (msg: string) => void = () => {}, +): Promise { + if (!creds.token || !creds.orgId) return creds; + const payload = decodeJwtPayload(creds.token); + const claimOrg = payload && typeof payload.org_id === "string" ? payload.org_id : undefined; + if (!claimOrg || claimOrg === creds.orgId) return creds; + log(`token org drift detected: jwt.org_id=${claimOrg} creds.orgId=${creds.orgId} — re-minting`); + try { + const apiUrl = creds.apiUrl ?? DEFAULT_API_URL; + // Per-mint unique name. Deeplake rejects duplicate (user_id, name) with + // a 500 ("token creation failed"), and the heal runs on EVERY session + // start across multiple agents — a date-only suffix would collide as + // soon as the second agent heals on the same day. Date.now() suffices: + // resolution is ms, only one heal per session, single process per agent. + const tokenName = `deeplake-plugin-heal-${Date.now()}`; + const tokenData = await apiPost("/users/me/tokens", { + name: tokenName, + duration: 365 * 24 * 3600, + organization_id: creds.orgId, + }, creds.token, apiUrl) as { token: { token: string } }; + const healed: Credentials = { ...creds, token: tokenData.token.token }; +``` + +--- + +## Operational Database Management: Session Pruning + +As coding sessions accumulate, users need a way to inspect, prune, and clear their captured trace history. The `hivemind sessions prune` subcommand provides scoped cleanup of session data by the logged-in author. + +The pruning command uses direct, safe SQL statements to operate on both the `sessions` table (where raw event traces are stored) and the `memory` table (where session summaries reside). + +### Querying and Filtering Sessions + +Pruning first queries the sessions table to group events by their session path, extracting the event counts, dates, and active projects: + +```70:90:src/commands/session-prune.ts +async function listSessions( + api: DeeplakeApi, + sessionsTable: string, + author: string, +): Promise { + const rows = await api.query( + `SELECT path, COUNT(*) as cnt, MIN(creation_date) as first_event, ` + + `MAX(creation_date) as last_event, MAX(project) as project ` + + `FROM "${sessionsTable}" WHERE author = '${sqlStr(author)}' ` + + `GROUP BY path ORDER BY first_event DESC` + ); + + return rows.map(r => ({ + path: String(r.path), + rowCount: Number(r.cnt), + firstEvent: String(r.first_event), + lastEvent: String(r.last_event), + project: String(r.project ?? ""), + })); +} +``` + +### Performing Deletion + +The client filters the sessions matching the user's criteria (such as `--before ` or `--session-id `). For each target, it executes a DELETE statement on the sessions table and removes the corresponding summary from the memory table: + +```91:133:src/commands/session-prune.ts +async function deleteSessions( + config: Config, + sessionPaths: string[], +): Promise<{ sessionsDeleted: number; summariesDeleted: number }> { + if (sessionPaths.length === 0) return { sessionsDeleted: 0, summariesDeleted: 0 }; + + const sessionsApi = new DeeplakeApi( + config.token, config.apiUrl, config.orgId, config.workspaceId, + config.sessionsTableName, + ); + const memoryApi = new DeeplakeApi( + config.token, config.apiUrl, config.orgId, config.workspaceId, + config.tableName, + ); + + let sessionsDeleted = 0; + let summariesDeleted = 0; + + for (const sessionPath of sessionPaths) { + // Delete all rows for this session from the sessions table + await sessionsApi.query( + `DELETE FROM "${config.sessionsTableName}" WHERE path = '${sqlStr(sessionPath)}'` + ); + sessionsDeleted++; + + // Delete the corresponding summary from the memory table + // Summary path: /summaries//.md + const sessionId = extractSessionId(sessionPath); + const summaryPath = `/summaries/${config.userName}/${sessionId}.md`; + + const existing = await memoryApi.query( + `SELECT path FROM "${config.tableName}" WHERE path = '${sqlStr(summaryPath)}' LIMIT 1` + ); + if (existing.length > 0) { + await memoryApi.query( + `DELETE FROM "${config.tableName}" WHERE path = '${sqlStr(summaryPath)}'` + ); + summariesDeleted++; + } + } + + return { sessionsDeleted, summariesDeleted }; +} +``` + +This ensures that trace history and their generated summaries never get out of sync, preventing empty references or orphaned summary entries in the database. diff --git a/library/knowledge/private/operations/notifications-and-health.md b/library/knowledge/private/operations/notifications-and-health.md new file mode 100644 index 00000000..d9116a32 --- /dev/null +++ b/library/knowledge/private/operations/notifications-and-health.md @@ -0,0 +1,149 @@ +# Notifications and Environment Health + +> Category: Operations | Version: 1.0 | Date: June 2026 | Status: Active + +Architecture of the Hivemind notifications framework and the proactive prerequisite environment health check and auto-wiring engine. + +**Related:** +- [`cli-command-architecture.md`](cli-command-architecture.md) +- [`../auth/auth-architecture.md`](../auth/auth-architecture.md) +- [`../overview.md`](../overview.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../architecture/session-lifecycle.md`](../architecture/session-lifecycle.md) + +--- + +## Why this exists + +Coding assistant integrations are complex and fragile. They are highly dependent on external command-line utilities, correct user sessions, and global configuration files like `~/.cursor/hooks.json`. + +To prevent silent failures, Hivemind implements two proactive operational guardrails: +1. **The Notifications Framework:** Evaluates, queues, and delivers contextual alerts on session start, helping developers resolve subscription issues, account limits, and local mining opportunities. +2. **The Environment Health Check:** Continuously monitors local prerequisites, verifies compiler tools and helper CLIs, and auto-wires lifecycle hooks with near-zero friction. + +Together, these guardrails ensure that the shared memory layer remains robust, and that potential compilation or summarization failures are caught and surfaced before causing silent data loss. + +--- + +## The Notifications Framework + +The notifications pipeline is trigger-agnostic and fail-soft. It is designed to run synchronously during `SessionStart` without introducing visible latency into the user's coding session. + +```77:104:src/notifications/index.ts +export async function drainSessionStart(opts: DrainOptions): Promise { + try { + const state = readState(); + const queue = readQueue(); + const ctx: NotificationContext = { + agent: opts.agent, + creds: opts.creds, + state, + localSkillsCount: opts.localSkillsCount ?? null, + latestInsightEntry: opts.latestInsightEntry ?? null, + sessionCount: opts.sessionCount, + }; + + const fromRules = evaluateRules("session_start", ctx); + const fromQueue = queue.queue; + // Two parallel fetches with independent 1.5s timeouts so session-start + // latency stays bounded by ~1.5s rather than 3s. Both fail-soft. + // + // pickPrimaryBanner returns the single banner for the welcome/savings + // priority slot (org savings > 1M → savings recap; else → welcome). + // Backend pushes remain additive in this PR — they're rare and not yet + // under the priority model. A follow-up will collapse all sources + // (including queue) under the same priority. + const [fromBackend, primary] = await Promise.all([ + fetchBackendNotifications(opts.creds), + pickPrimaryBanner(opts.sessionId, opts.creds, opts.source), + ]); +``` + +### Double-Invocation Race Mitigation + +In some environments, such as Claude Code, the notifications hook can be registered in both the user's global configuration (`~/.claude/settings.json`) and the marketplace plugin definition (`hooks.json`). This causes two separate Node processes to spawn and run in parallel, both reading state before either writes. + +To prevent duplicate banners from cluttering the terminal, Hivemind implements an atomic claiming lock using POSIX file semantics: + +```114:133:src/notifications/state.ts +export function tryClaim(n: Notification): boolean { + const home = resolve(homedir()); + const claimsDir = join(home, ".deeplake", "notifications-claims"); + try { + mkdirSync(claimsDir, { recursive: true, mode: 0o700 }); + } catch (e: any) { + log(`tryClaim mkdir failed: ${e?.message ?? String(e)}`); + return true; + } + const claimPath = claimPathFor(claimsDir, n); + try { + const fd = openSync(claimPath, "wx", 0o600); + closeSync(fd); + return true; + } catch (e: any) { + if (e?.code === "EEXIST") return false; + log(`tryClaim open failed: ${e?.message ?? String(e)}`); + return true; + } +} +``` + +The first process to call `openSync` with the exclusive write-creation flag (`wx`) succeeds and gains the claim. The racer process encounters an `EEXIST` error and immediately skips emitting that notification. + +### Transient vs. Persistent States + +* **Persistent Notifications:** Welcome messages, first-time guides, or organization-wide savings recaps are registered in state. Storing their `id` and `dedupKey` in `~/.deeplake/notifications-state.json` ensures they display exactly once. +* **Transient Notifications:** Used for self-clearing events, such as payment failures or missing background dependencies. When a transient notification is drained, its claim file is unlinked using `releaseClaim`, allowing future sessions to re-emit the warning if the underlying issue continues. + +To prevent filesystem corruption during state updates, `writeState` writes output to a temporary process-tagged file first, then executes an atomic POSIX `renameSync` operation over the active state path. + +--- + +## Environment Health Check (D1 - D4) + +The health check resolves the silent-failure gap described in `prd-002a-health-check.md`. If a background summary worker fails because a compiler or tool binary is missing, the error was previously swallowed. The check proactive monitors four independent dimensions of environment health. + +| Dimension | Checked Precondition | Resolving Strategy | +| --- | --- | --- | +| **D1: `hivemind` CLI** | Is the global `hivemind` CLI binary installed? | PATH resolution with version probing. | +| **D2: `cursor-agent` CLI** | Is `cursor-agent` present and executable? | PATH resolution with fallbacks to known IDE directories. | +| **D3: `cursor-agent` login** | Is the user logged into `cursor-agent`? | A lightweight status query command. | +| **D4: Hooks wired and current** | Are the correct lifecycle hooks present? | Checks `hooks.json` for matches against the current bundle. | + +Surfacing logged-out and missing states upfront prevents the shared database from filling with silent, empty placeholders. + +--- + +## Auto-Wiring and Idempotency + +The auto-wiring engine removes the friction of manual hook setup by managing the `~/.cursor/hooks.json` configuration file on the developer's behalf. + +The engine wires six specific lifecycle events to redirect agent actions through the Hivemind shared core: + +```44:61:src/cli/install-cursor.ts +function buildHookConfig(): Record { + return { + sessionStart: [buildHookCmd("session-start.js", 30)], + beforeSubmitPrompt: [buildHookCmd("capture.js", 10)], + // preToolUse with Shell matcher rewrites grep/rg against ~/.deeplake/memory/ + // into a single SQL fast-path call, matching Claude Code / Codex accuracy. + preToolUse: [buildHookCmdShellMatcher("pre-tool-use.js", 30)], + postToolUse: [buildHookCmd("capture.js", 15)], + afterAgentResponse: [buildHookCmd("capture.js", 15)], + // graph-on-stop: auto-build the code graph (A1 Cursor parity). Same hook + // Claude Code registers under Stop + SessionEnd. It's gated (rate limit + + // HEAD-changed + source-diff) so the common path is a ~5ms skip, and runs + // async so it never blocks Cursor. + stop: [buildHookCmd("capture.js", 15), buildHookCmd("graph-on-stop.js", 30)], + sessionEnd: [buildHookCmd("session-end.js", 30), buildHookCmd("graph-on-stop.js", 30)], + }; +} +``` + +### Correctness and Safety Requirements + +To operate safely inside developer environments, the auto-wiring process adheres to three strict rules: + +1. **Preserving Foreign Hooks:** Auto-wiring must never overwrite other third-party hooks. It parses the existing array, filters out entries matching Hivemind paths via `isHivemindEntry`, and appends the new configuration. +2. **Idempotency:** Re-wiring when no configuration has changed must not touch the file. This protects the hook-trust fingerprint calculated by the editor, avoiding warning dialogs. This is implemented using `writeJsonIfChanged` under the hood. +3. **Reversibility:** When uninstalling, the engine strips only the Hivemind hooks. If the resulting `hooks` object contains no further hooks, the configuration file itself is cleanly unlinked. diff --git a/library/knowledge/private/overview.md b/library/knowledge/private/overview.md new file mode 100644 index 00000000..0e12e774 --- /dev/null +++ b/library/knowledge/private/overview.md @@ -0,0 +1,161 @@ +# Hivemind Knowledge Base + +> Category: Overview | Version: 1.0 | Date: June 2026 | Status: Active + +The entry point for everyone working on Hivemind internals: what the product is, how its pieces fit together, and where to read next. + +**Related:** +- [`architecture/system-overview.md`](architecture/system-overview.md) +- [`architecture/session-lifecycle.md`](architecture/session-lifecycle.md) +- [`data/deeplake-tables-schema.md`](data/deeplake-tables-schema.md) +- [`auth/auth-architecture.md`](auth/auth-architecture.md) +- [`plugins/integration-model.md`](plugins/integration-model.md) +- [`../../../docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md) + +--- + +## What Hivemind is + +Hivemind is a shared, auto-learning memory layer for coding agents. It gives Claude Code, OpenClaw, Codex, Cursor, Hermes, and pi a single brain: one agent solves a problem on Monday, and every agent on the team can recall and reuse that work afterward. The pitch in the README is "one brain for all your agents," and the mechanics behind it are Capture, Codify, Propagate, Compound. + +The product is not a server with a UI. It is a monorepo of plugins, hooks, and a CLI that install into each supported assistant and quietly wire into that assistant's lifecycle events. Every prompt, tool call, and response is captured as a structured trace in Deeplake (a tensor-native database). A background worker mines those traces into reusable `SKILL.md` files, and codified skills propagate back into every connected agent's context at inference time. On the LoCoMo long-context memory benchmark, this approach is 25% cheaper, uses 1.7x fewer tokens, and reaches answers in 31% fewer turns than running with no shared memory. + +Installation is one command: `npm i -g @deeplake/hivemind && hivemind install` detects every supported assistant, wires its hooks, and runs a device-flow login. From then on, capture and recall happen automatically with no further user action. + +--- + +## Top-level architecture + +Hivemind has four moving parts that recur across every domain. + +**Per-agent integration shims.** Each supported assistant exposes a different extension surface: Claude Code takes a marketplace plugin, Codex and Cursor take a `hooks.json`, OpenClaw takes a native extension, Hermes takes shell hooks plus an MCP server, and pi takes a TypeScript extension plus an `AGENTS.md` block. The shims translate each assistant's native lifecycle events into the same capture and recall calls. + +**The shared core (`src/`).** The Deeplake API client (`src/deeplake-api.ts`), the table schemas (`src/deeplake-schema.ts`), config loading (`src/config.ts`), credential handling (`src/commands/auth.ts`), and the SQL-safety utilities are all agent-agnostic. The per-agent hooks are thin wrappers over this core. + +**Deeplake as the substrate.** All durable state lives in Deeplake tables: `sessions` (raw per-event traces), `memory` (wiki summaries plus the virtual filesystem), `skills`, `rules`, `goals`, `kpis`, and `codebase` (the code graph). Org and workspace boundaries are enforced at the storage layer, so two workspaces never share a row, partition, or index. + +**The virtual filesystem (VFS).** Agents read and write memory through ordinary shell commands (`cat`, `ls`, `grep`) against `~/.deeplake/memory/`. A PreToolUse hook intercepts those commands and routes them to SQL queries instead of the real disk, which is how recall feels like browsing files while actually hitting a team-shared database. + +External dependencies are intentionally few: Deeplake for storage, the host agent's own CLI (`claude -p`, `codex exec`, `pi --print`) for summary generation so no extra API key is needed, and an optional local nomic-embed daemon for semantic search. + +--- + +## Key components + +| Component | Location | Responsibility | +|---|---|---| +| Shared core | `src/` | API client, schemas, config, auth, SQL utils | +| Claude Code hooks | `src/hooks/` | Reference implementation of every lifecycle hook | +| Per-agent hooks | `src/hooks/{codex,cursor,hermes,pi}/` | Agent-specific capture, recall, and summary shims | +| Unified CLI | `src/cli/` | `hivemind install` plus per-agent installers | +| Commands | `src/commands/` | auth, login, session-prune, goals, rules | +| Embeddings | `src/embeddings/` | nomic embed daemon, protocol, SQL helpers | +| MCP server | `src/mcp/` | Recall surface for Hermes and future MCP clients | +| Skillify | `src/skillify/` | Trace-to-skill mining pipeline | +| Graph | `src/graph/` | Live codebase graph extraction and recall | +| Plugin build outputs | `harnesses/claude-code/`, `harnesses/codex/`, `cursor/`, `harnesses/hermes/`, `harnesses/openclaw/`, `harnesses/pi/` | Distributable artifacts per assistant | + +--- + +## Reading guide + +Read this overview first, then [`architecture/system-overview.md`](architecture/system-overview.md) for the monorepo map, then [`architecture/session-lifecycle.md`](architecture/session-lifecycle.md) for the per-session hook flow. The table below links every knowledge doc by domain. Shorter companion references also live under [`../../../docs/`](../../../docs/) at the repo root. + +### Architecture + +| Doc | Covers | +|---|---| +| [`architecture/system-overview.md`](architecture/system-overview.md) | Monorepo layout, subsystems, integration model | +| [`architecture/session-lifecycle.md`](architecture/session-lifecycle.md) | SessionStart through capture, recall, and summary workers | + +### Plugins + +| Doc | Covers | +|---|---| +| [`plugins/integration-model.md`](plugins/integration-model.md) | Per-assistant install surfaces and event wiring | +| [`plugins/hook-lifecycle.md`](plugins/hook-lifecycle.md) | Hook event matrix and handler dispatch | +| [`plugins/mcp-and-extension-surfaces.md`](plugins/mcp-and-extension-surfaces.md) | MCP server, OpenClaw extension, Hermes skill | + +### AI + +| Doc | Covers | +|---|---| +| [`ai/session-capture.md`](ai/session-capture.md) | Per-event INSERT capture and embedding attachment | +| [`ai/wiki-summary-workers.md`](ai/wiki-summary-workers.md) | Detached summary workers and host-CLI generation | +| [`ai/skillify-pipeline.md`](ai/skillify-pipeline.md) | Trace mining, gate model, skill propagation | +| [`ai/embeddings-retrieval.md`](ai/embeddings-retrieval.md) | nomic embed daemon and hybrid recall | + +### Data + +| Doc | Covers | +|---|---| +| [`data/deeplake-tables-schema.md`](data/deeplake-tables-schema.md) | Full table DDL for every Deeplake table | +| [`data/memory-virtual-filesystem.md`](data/memory-virtual-filesystem.md) | VFS path conventions and SQL dispatch | +| [`data/codebase-graph.md`](data/codebase-graph.md) | Live graph build, push, and recall | + +### Auth and security + +| Doc | Covers | +|---|---| +| [`auth/auth-architecture.md`](auth/auth-architecture.md) | Device-flow login, org binding, token healing | +| [`security/trust-boundaries.md`](security/trust-boundaries.md) | VFS allowlist, SQL safety, tenant isolation | +| [`security/credential-storage.md`](security/credential-storage.md) | On-disk credential layout and permissions | + +### Frontend, multi-tenant, and collaboration + +| Doc | Covers | +|---|---| +| [`frontend/cursor-extension-architecture.md`](frontend/cursor-extension-architecture.md) | Cursor hooks bundle and dashboard surface | +| [`multi-tenant/org-workspace-model.md`](multi-tenant/org-workspace-model.md) | Org and workspace boundaries at storage | +| [`collaboration/team-skills-sharing.md`](collaboration/team-skills-sharing.md) | Cross-teammate skill pull and rules propagation | + +### Infrastructure and operations + +| Doc | Covers | +|---|---| +| [`infrastructure/monorepo-build-release.md`](infrastructure/monorepo-build-release.md) | tsc, esbuild bundles, and distribution | +| [`operations/cli-command-architecture.md`](operations/cli-command-architecture.md) | Unified CLI routing and auth subcommands | +| [`operations/notifications-and-health.md`](operations/notifications-and-health.md) | Session banners, savings recap, health checks | + +### Standards + +| Doc | Covers | +|---|---| +| [`standards/documentation-framework.md`](standards/documentation-framework.md) | Document types, headers, naming, and writing rules for this repo | + +### Root docs (shorter references) + +| Doc | Covers | +|---|---| +| [`../../../docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md) | High-level architecture summary | +| [`../../../docs/CAPTURE_TASKS.md`](../../../docs/CAPTURE_TASKS.md) | Explicit save-and-resume capture tasks | +| [`../../../docs/EMBEDDINGS.md`](../../../docs/EMBEDDINGS.md) | Embedding daemon quick reference | +| [`../../../docs/SKILLIFY.md`](../../../docs/SKILLIFY.md) | Skillify pipeline quick reference | +| [`../../../docs/SUMMARIES.md`](../../../docs/SUMMARIES.md) | Wiki summary worker quick reference | + +--- + +## Where to start by task + +**New to the codebase:** this overview, then [`architecture/system-overview.md`](architecture/system-overview.md), then [`architecture/session-lifecycle.md`](architecture/session-lifecycle.md). + +**Working on capture or recall:** [`architecture/session-lifecycle.md`](architecture/session-lifecycle.md), [`ai/session-capture.md`](ai/session-capture.md), [`data/memory-virtual-filesystem.md`](data/memory-virtual-filesystem.md). + +**Working on a specific assistant integration:** [`plugins/integration-model.md`](plugins/integration-model.md) and the matching `src/hooks//` directory. + +**Investigating storage or schema:** [`data/deeplake-tables-schema.md`](data/deeplake-tables-schema.md) (canonical DDL); `src/deeplake-schema.ts` is the runtime source of truth. + +**Auth, credentials, or tenancy:** [`auth/auth-architecture.md`](auth/auth-architecture.md), [`security/credential-storage.md`](security/credential-storage.md), [`multi-tenant/org-workspace-model.md`](multi-tenant/org-workspace-model.md). + +**Build, release, or CLI:** [`infrastructure/monorepo-build-release.md`](infrastructure/monorepo-build-release.md), [`operations/cli-command-architecture.md`](operations/cli-command-architecture.md). + +**Writing or filing docs:** [`standards/documentation-framework.md`](standards/documentation-framework.md). + +--- + +## Coverage stats + +- Knowledge docs authored: 22 (this overview plus 21 domain docs) +- Domains covered: architecture, plugins, AI, data, auth, security, frontend, multi-tenant, collaboration, infrastructure, operations, standards +- Source material: `README.md`, `docs/`, and the `src/` tree +- Last updated: June 2026 diff --git a/library/knowledge/private/plugins/hook-lifecycle.md b/library/knowledge/private/plugins/hook-lifecycle.md new file mode 100644 index 00000000..80f32e59 --- /dev/null +++ b/library/knowledge/private/plugins/hook-lifecycle.md @@ -0,0 +1,195 @@ +# Hook Lifecycle + +> Category: Plugins | Version: 1.0 | Date: June 2026 | Status: Active + +Which hook events fire on each agent, what each hook does, which implementation files are shared across agents, and which are agent-specific shims. + +**Related:** +- [`integration-model.md`](integration-model.md) +- [`mcp-and-extension-surfaces.md`](mcp-and-extension-surfaces.md) +- [`../security/trust-boundaries.md`](../security/trust-boundaries.md) +- [`../architecture/session-lifecycle.md`](../architecture/session-lifecycle.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../overview.md`](../overview.md) +- [`../../../../docs/ARCHITECTURE.md`](../../../../docs/ARCHITECTURE.md) + +--- + +## Hook event coverage by agent + +Each assistant has its own event vocabulary. The table below maps the logical Hivemind events to the names each agent actually emits. + +| Logical event | Claude Code | Codex | Cursor (1.7+) | OpenClaw | Hermes | pi | +|---|---|---|---|---|---|---| +| Session start / recall inject | `SessionStart` | `SessionStart` | `sessionStart` | `before_agent_start` + `before_prompt_build` | `on_session_start` | AGENTS.md static block | +| Prompt capture | `UserPromptSubmit` | `UserPromptSubmit` | `beforeSubmitPrompt` | `agent_end` (message batch) | `on_user_message` | `agent_end` | +| Pre-tool intercept (VFS recall) | `PreToolUse` | `PreToolUse(Bash)` | `postToolUse` / `beforeSubmitPrompt` | N/A (tool registration) | `on_tool_use` (terminal only) | N/A | +| Post-tool capture | `PostToolUse` | `PostToolUse` | `postToolUse` | `agent_end` (message batch) | N/A | N/A | +| Assistant response capture | `Stop` / `SubagentStop` | `Stop` | `afterAgentResponse` / `stop` | `agent_end` (message batch) | N/A | N/A | +| Session end / summary spawn | `SessionEnd` | N/A (periodic only) | `sessionEnd` | `agent_end` (with wiki worker spawn) | `on_session_end` | `session_shutdown` | + +A blank cell means that event is not available on that assistant. The lifecycle is still functionally complete: OpenClaw, for example, batches capture across the full conversation in `agent_end` rather than per-event, producing the same rows in the `sessions` table, just grouped into one flush rather than written incrementally. + +--- + +## The shared core files + +The code paths that actually perform capture, recall, and summary generation are agent-agnostic. Every per-agent shim calls into these shared modules: + +| File | Role | +|---|---| +| `src/hooks/capture.ts` | Reference capture implementation (Claude Code). Writes one row per event to the `sessions` table. | +| `src/hooks/session-start.ts` | Reference SessionStart (Claude Code). Authenticates, heals drift, ensures tables, writes placeholder, renders context block. | +| `src/hooks/pre-tool-use.ts` | Reference VFS intercept (Claude Code). Routes Bash/Read/Grep/Glob on the memory path to SQL queries. | +| `src/hooks/session-end.ts` | Reference SessionEnd (Claude Code). Marks session ended, records usage, fires skillify, spawns wiki worker. | +| `src/hooks/wiki-worker.ts` | Background summary worker (Claude Code). Reads session rows, shells `claude -p`, uploads result to `memory` table. | +| `src/hooks/spawn-wiki-worker.ts` | Detached spawn helper. Writes a temp config JSON and forks `wiki-worker.js` as an unref'd child. | +| `src/hooks/shared/context-renderer.ts` | Renders the rules and goals block injected at SessionStart. Read-only; absorbs its own errors. | +| `src/hooks/shared/capture-gate.ts` | `HIVEMIND_CAPTURE !== "false"` gate and only-CLI entrypoint check used by every capture path. | +| `src/hooks/shared/autoupdate.ts` | `autoUpdate(creds, { agent })`: checks npm registry, spawns `hivemind update` if a newer version is found. | +| `src/hooks/shared/goals-instructions.ts` | `GOALS_INSTRUCTIONS_CLI`: the CLI-variant goal-management instructions injected for Cursor, Hermes, and pi. | +| `src/hooks/shared/skillopt-hook.ts` | Arms the skill-optimization counter when the agent invokes an org skill. | +| `src/hooks/summary-state.ts` | Per-session sidecar: `clearSessionEnded`, `markSessionEnded`, `tryAcquireLock`, `releaseLock`, `finalizeSummary`. | + +--- + +## Per-agent shim directories + +Each agent that needs behavior different from the Claude Code reference gets its own subdirectory under `src/hooks/`. + +``` +src/hooks/ +├── capture.ts ← reference +├── session-start.ts ← reference +├── pre-tool-use.ts ← reference +├── session-end.ts ← reference +├── wiki-worker.ts ← reference (shells claude -p) +├── spawn-wiki-worker.ts ← reference +├── harnesses/codex/ +│ ├── session-start.ts # minimal inject; async setup in session-start-setup.ts +│ ├── session-start-setup.ts # table DDL, placeholder, version check (detached) +│ ├── capture.ts # same logic, Codex payload shape +│ ├── pre-tool-use.ts # intercepts Bash only +│ ├── wiki-worker.ts # shells `codex exec --dangerously-bypass-approvals-and-sandbox` +│ └── spawn-wiki-worker.ts # same pattern, Codex config keys +├── cursor/ +│ ├── session-start.ts # additional_context key, workspace_roots for cwd +│ ├── capture.ts # same logic, Cursor payload shape +│ ├── pre-tool-use.ts # Shell intercept, same VFS logic +│ ├── session-end.ts # sessionEnd event name +│ ├── wiki-worker.ts # shells `cursor-agent` or falls back to claude +│ └── spawn-wiki-worker.ts # Cursor config keys +├── harnesses/hermes/ +│ ├── session-start.ts # { context: "..." } output, MCP tools mention in inject +│ ├── capture.ts # same logic, Hermes payload shape +│ ├── pre-tool-use.ts # terminal tool intercept only +│ ├── session-end.ts # on_session_end event +│ ├── wiki-worker.ts # shells `hermes` in non-interactive mode +│ └── spawn-wiki-worker.ts # Hermes config keys +└── harnesses/pi/ + └── wiki-worker.ts # shells `pi --print --provider

        --model ` +``` + +The pi extension itself lives at `harnesses/pi/extension-source/hivemind.ts` (installed as `~/.pi/agent/hivemind/hivemind.ts`). It contains the pi-native session hooks and spawns the wiki worker. Only the wiki worker is in `src/hooks/pi/` because the extension entry point is pi-specific TypeScript that pi compiles directly. + +--- + +## What each hook event does + +### Session start + +The SessionStart hook runs once when the assistant opens a new session. Its responsibilities, in order, are: + +1. Load credentials from `~/.deeplake/credentials.json`. On a fresh box with no token, either prompt for login (OpenClaw, pi) or log a notice and continue read-only (all others). +2. Heal token/org drift with `healDriftedOrgToken` if credentials are present. +3. Run `autoUpdate` before any database calls so the update notice appears even if the backend is slow. (Not in Codex, which defers this to the detached setup process.) +4. Ensure the `memory` and `sessions` tables exist (`api.ensureTable()`, `api.ensureSessionsTable()`). Gated on `HIVEMIND_CAPTURE !== "false"`. +5. Write a placeholder summary row so the session is visible in the index while it is in progress. Gated on capture. +6. Render the rules/goals context block via `renderContextBlock`. Read-only; runs regardless of the capture gate. +7. Pull skills from teammates with `autoPullSkills`. +8. Spawn the graph pull worker (`spawnGraphPullWorker`) for the next session's codebase context. +9. Return the assembled `additionalContext` (or equivalent) to the assistant. + +Claude Code and Cursor both receive a full context block (memory tier docs, memory commands, skillify commands, rules, goals, graph line) because their `additionalContext` is model-only. Codex receives only a brief login-state line because its `hook context:` is user-visible. Hermes receives the full block despite TUI visibility because the structural index is considered worth the extra lines. pi reads from the static `AGENTS.md` block. + +### Per-turn capture + +Capture handles three event types and writes one row per event to the `sessions` table: + +- **UserPromptSubmit / beforeSubmitPrompt** (`user_message` row): records the user's prompt text. +- **PostToolUse** (`tool_call` row): records the tool name, input, and response. +- **Stop / SubagentStop / afterAgentResponse** (`assistant_message` row): records the assistant's last message. + +Each row carries session metadata (session id, cwd, permission mode, hook event name, agent id) and an optional `message_embedding` vector. If the INSERT fails because the table does not exist yet (the SessionStart ensure failed), the hook creates the table and retries once. + +On a Stop event, `capture.ts` additionally calls `tryStopCounterTrigger` from `src/skillify/triggers.ts`, which may fire the skillify miner independently of the wiki summary worker. + +OpenClaw batches capture differently: `agent_end` delivers the full conversation as a `messages` array and the hook processes only the slice of new messages since the previous flush. + +### Pre-tool-use (VFS recall) + +The PreToolUse hook is the VFS intercept. It runs before every tool execution and looks for Bash, Read, Grep, or Glob calls whose paths start with `~/.deeplake/memory/`. When it sees one, it rewrites the call into a SQL-backed response: + +- `cat` / `Read` on a path becomes a direct row read from the `memory` or `sessions` table via `readVirtualPathContent`. +- `grep` / `Glob` becomes a hybrid lexical-plus-semantic search through `handleGrepDirect` (in `src/hooks/grep-direct.ts`). +- `ls` becomes a path-prefix listing. +- `find` becomes a path-pattern query. + +Write and Edit on a memory path are denied with guidance to use Bash instead. Commands that the VFS cannot model (interpreters, pipes, command substitution) are rewritten to a harmless `echo` so nothing escapes to the real filesystem. Codex and Hermes intercept only Bash/terminal, so Read-tool memory access is less available on those assistants. OpenClaw and pi have no PreToolUse hook at all. + +```mermaid +flowchart TD + hookFire["PreToolUse fires"] --> isMemoryPath{"path starts with\n~/.deeplake/memory/?"} + isMemoryPath -- no --> passThrough["Pass through unchanged"] + isMemoryPath -- yes --> routeCmd{"command type?"} + routeCmd -- "cat / Read" --> sqlRead["readVirtualPathContent\n→ SELECT row"] + routeCmd -- "grep / Glob" --> sqlSearch["handleGrepDirect\n→ hybrid lexical+semantic"] + routeCmd -- "ls" --> sqlList["path-prefix listing"] + routeCmd -- "find" --> sqlPattern["path-pattern query"] + routeCmd -- "Write / Edit" --> denyWrite["Return deny guidance"] + routeCmd -- "interpreter / pipe" --> safeEcho["Rewrite to echo + retry guidance"] + sqlRead --> emitResult["Emit result via printf\nagent sees file output"] + sqlSearch --> emitResult + sqlList --> emitResult + sqlPattern --> emitResult +``` + +### Session end + +The SessionEnd hook exits fast and pushes all work to detached processes: + +1. Call `markSessionEnded` so other sessions stop treating this one as live. +2. Call `recordSessionUsage` to parse the transcript for memory-search activity and append a record to `~/.deeplake/usage-stats.jsonl`. +3. Call `forceSessionEndTrigger` to run skillify mining (uses its own per-project lock, independent of the summary lock). +4. Acquire the per-session summary lock and spawn the wiki worker with reason `SessionEnd`. + +If the spawn throws before the worker takes ownership, the hook releases the lock so a `--resume` can retrigger summaries. The detached worker reads the session's events from the `sessions` table, shells the host CLI, and writes the finished summary to the `memory` table. This is covered in detail in `session-lifecycle.md`. + +--- + +## Shared vs agent-specific behavior summary + +```mermaid +flowchart LR + subgraph shared["Shared core (always the same)"] + capGate["Capture gate check"] + deeplakeInsert["INSERT to sessions table"] + vfsRoute["VFS path routing"] + summaryWorker["Wiki worker spawn"] + skillify["Skillify trigger"] + contextRender["Rules/goals render"] + end + + subgraph agentSpecific["Agent-specific shims"] + eventNames["Event name mapping"] + payloadShape["Payload field normalization"] + contextChannel["Context injection channel\n(model-only vs user-visible)"] + toolSurface["Tool surface\n(hook intercept vs registered tool)"] + hostCli["Host CLI for summary\n(claude / codex / cursor-agent / pi)"] + asyncPattern["Async pattern\n(inline vs detached setup)"] + end + + agentSpecific --> shared +``` + +The shims are intentionally thin. Their only job is to normalize the incoming payload into the shape the shared core expects and to route the output back through the assistant's specific response format. All memory decisions, all SQL, all embedding calls, and all locking happen in the shared core. diff --git a/library/knowledge/private/plugins/integration-model.md b/library/knowledge/private/plugins/integration-model.md new file mode 100644 index 00000000..056f0d5e --- /dev/null +++ b/library/knowledge/private/plugins/integration-model.md @@ -0,0 +1,113 @@ +# Integration Model + +> Category: Plugins | Version: 1.0 | Date: June 2026 | Status: Active + +How each of the six supported coding assistants is wired to Hivemind: the distribution channel, the installation path, the capture and recall mechanism, and the notable behavioral differences between agents. + +**Related:** +- [`hook-lifecycle.md`](hook-lifecycle.md) +- [`mcp-and-extension-surfaces.md`](mcp-and-extension-surfaces.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../architecture/session-lifecycle.md`](../architecture/session-lifecycle.md) +- [`../infrastructure/monorepo-build-release.md`](../infrastructure/monorepo-build-release.md) +- [`../overview.md`](../overview.md) +- [`../../../../docs/ARCHITECTURE.md`](../../../../docs/ARCHITECTURE.md) + +--- + +## Why six different mechanisms + +No two coding assistants expose the same extension surface. Claude Code offers a first-party marketplace that injects plugins into its hook pipeline. Codex and Cursor both use a `hooks.json` convention but differ in payload shapes and in whether `additionalContext` is model-only or also user-visible. OpenClaw has a native extension contract with its own gateway process. Hermes wires through shell hooks plus an MCP server. pi combines a TypeScript extension with a static `AGENTS.md` block. + +The Hivemind answer is the same in every case: map the assistant's native lifecycle events onto the shared `capture.ts` and recall logic in `src/`, emit the same row format to Deeplake, and confine all per-agent differences to the thin shims under `src/hooks/{codex,cursor,hermes,pi}/`. Adding a new assistant means writing a new shim, not a new memory engine. + +--- + +## Integration table + +The table below expands `docs/ARCHITECTURE.md` with the install path, distribution channel, additionalContext visibility, and the specific behavioral gaps that exist at each integration point. + +| Agent | Mechanism | Install path | Distribution | Context visibility | Notes | +|---|---|---|---|---|---| +| Claude Code | Marketplace plugin | `~/.claude/plugins/hivemind/` | Claude Code Marketplace | Model-only (`additionalContext`) | Reference implementation. Full hook set. Rules and goals injected every SessionStart. | +| Codex | `~/.codex/hooks.json` | `~/.codex/hooks.json` + bundle | npm (`@deeplake/hivemind`) | User-visible (`hook context: ` in TUI) | Async setup spawned as a detached process because Codex has no async hook channel. Rules/goals block deliberately omitted to avoid TUI clutter. | +| Cursor (1.7+) | `~/.cursor/hooks.json` | `~/.cursor/hooks.json` + bundle | npm | Model-only (`additional_context`) | Fully parallel to Claude Code. Goals injected via CLI variant because Cursor's PreToolUse only intercepts Shell, not Write/Edit. | +| OpenClaw | Native extension | `~/.openclaw/extensions/hivemind/` | ClawHub | Model-only (via `before_prompt_build` system context) | Event-driven: `agent_end` for capture, `before_agent_start` for recall. No PreToolUse VFS intercept; exposes agent-facing `hivemind_search`/`hivemind_read`/`hivemind_index` tools directly. | +| Hermes | Shell hooks + MCP server | `~/.hermes/skills/hivemind-memory/` | npm | User-visible in TUI (same wire as `context`) | MCP tools (`hivemind_search`, `hivemind_read`, `hivemind_index`) registered alongside the shell hooks. Recall via grep on `~/.deeplake/memory/` or MCP. | +| pi | `~/.pi/agent/AGENTS.md` + TypeScript extension | `~/.pi/agent/hivemind/` | npm (raw `.ts`, compiled by pi) | Depends on pi configuration | No PreToolUse intercept available. Recall via grep on `~/.deeplake/memory/`. Wiki worker shelled via `pi --print`. | + +--- + +## Claude Code: the reference implementation + +Claude Code is the integration Hivemind was designed around. The plugin lives at `~/.claude/plugins/hivemind/` and is distributed through the Claude Code Marketplace. It wires every available lifecycle event: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, and `SessionEnd`. + +The `additionalContext` field in Claude Code's SessionStart response is invisible to the user and injected directly into the model's system prompt. This lets the full memory-usage instructions, rules block, goals block, skillify commands, and graph context line land in context without cluttering the conversation. + +The reference hook files are `src/hooks/session-start.ts`, `src/hooks/capture.ts`, `src/hooks/pre-tool-use.ts`, and `src/hooks/session-end.ts`. All per-agent variants are intentional derivations from these. + +--- + +## Codex: async setup and minimal injection + +Codex reads `~/.codex/hooks.json` and spawns each hook as a subprocess. Unlike Claude Code, there is no async hook channel, so the Codex SessionStart (`src/hooks/codex/session-start.ts`) immediately returns a minimal context string and fires the heavyweight work (table creation, placeholder write, version check) as a separate detached process via `session-start-setup.js`. + +Codex's `additionalContext` is rendered as `hook context: ` in the user-facing TUI history cell alongside both model context and user-visible entries. Because of this, the bulky memory-usage instructions and the rules/goals block that Claude Code injects are deliberately omitted for Codex. The Codex agent discovers memory tiers via `hivemind --help` and shell commands on demand. + +The PreToolUse hook (`src/hooks/codex/pre-tool-use.ts`) intercepts only `Bash` tool calls, not Read/Grep/Glob, matching Codex's narrower tool surface. + +--- + +## Cursor: parallel to Claude Code with CLI goal routing + +Cursor 1.7+ supports a `~/.cursor/hooks.json` convention with the same lifecycle event names as Claude Code (lowercase: `sessionStart`, `beforeSubmitPrompt`, `postToolUse`, `afterAgentResponse`, `stop`, `sessionEnd`). The Cursor shim (`src/hooks/cursor/`) is structurally the same as the Claude Code reference, with two differences. + +First, `additionalContext` in Cursor's SessionStart response is stored under the key `additional_context` (snake_case). It is injected as model-only context, so the full memory block and rules/goals are safe to include. + +Second, Cursor's PreToolUse only intercepts Shell tool calls. It cannot intercept Write or Edit on the memory path, so the goal management instructions use the CLI variant (`hivemind goal add/list/...` invoked as shell commands) rather than the Write-tool-intercept pattern available in Claude Code. + +--- + +## OpenClaw: native extension with contracted tools + +OpenClaw loads plugins from `~/.openclaw/extensions/hivemind/` as a native extension using a synchronous `register(pluginApi)` entry point defined in `harnesses/openclaw/src/index.ts`. The `register` function must return synchronously; all async work executes inside a fire-and-forget IIFE. + +The extension wires two hook events via `pluginApi.on(event, handler)`: + +- `before_prompt_build`: injects the SKILL.md body and any update/setup nudges into the system prompt as `prependSystemContext` so they hit the provider's prompt-cache path. +- `before_agent_start`: handles the login nudge (device-flow URL) and the post-auth welcome banner. +- `agent_end`: captures new messages from the conversation into the `sessions` table and fires the skillify worker. + +OpenClaw has no PreToolUse analog. Instead, the extension registers three agent-facing tools (`hivemind_search`, `hivemind_read`, `hivemind_index`) plus two write tools (`hivemind_goal_add`, `hivemind_kpi_add`) via `pluginApi.registerTool`. The SKILL.md body embedded at build time (`__HIVEMIND_SKILL__` constant) instructs the agent to call `hivemind_search` before answering questions about past work. OpenClaw also registers a `MemoryCorpusSupplement` so other OpenClaw plugins that expose a `memory_search` tool can federate queries into Hivemind automatically. + +Because OpenClaw's bundle scanner treats any `process.env` access in a file that also calls `fetch()` as `env-harvesting`, all `HIVEMIND_*` environment reads are rewritten by esbuild's `define` to `globalThis.__hivemind_tuning__?.HIVEMIND_X`, and `applyOpenclawTuning` bridges the user's `openclaw.json` plugin config into that global. + +--- + +## Hermes: shell hooks plus MCP + +Hermes wires Hivemind through a skill directory at `~/.hermes/skills/hivemind-memory/` that contains both shell hook scripts and an MCP server registration. The shell hooks fire on Hermes's event names and relay context via `{ context: "..." }` on stdout. The MCP server (`src/mcp/server.ts`) is spawned as a stdio subprocess and exposes `hivemind_search`, `hivemind_read`, and `hivemind_index` as MCP tools. + +The Hermes session-start hook (`src/hooks/hermes/session-start.ts`) is structurally identical to the Cursor variant: it authenticates, heals token drift, runs `autoUpdate`, creates a placeholder row, renders the rules/goals block, pulls skills, fires the graph pull worker, and returns the full context string. The key difference is that Hermes's context field is user-visible in the TUI, so longer injections are somewhat more prominent than in Claude Code or Cursor. + +Hermes's PreToolUse intercepts only the `terminal` tool, not Write or Edit. Goals are therefore routed via the CLI variant, same as Cursor. + +--- + +## pi: TypeScript extension and AGENTS.md + +pi loads the Hivemind extension from `~/.pi/agent/hivemind/` as raw TypeScript files that pi compiles at load time. There is no pre-compiled bundle; the extension source ships in `harnesses/pi/` under version control. A static `AGENTS.md` block at `~/.pi/agent/AGENTS.md` provides recall instructions that pi injects into every session's system context. + +pi has no PreToolUse hook. Recall happens via grep on `~/.deeplake/memory/`, as documented in the `AGENTS.md` block. The wiki worker (`src/hooks/pi/wiki-worker.ts`) is invoked by the extension and shells out to `pi --print --provider

        --model ` for summary generation, using pi's non-interactive mode so it does not recurse back into the extension. + +--- + +## Shared behavioral invariants + +Despite the mechanism differences, every integration shares the same invariants enforced by the shared core: + +- Every hook exits 0 on error. A crash or timeout in any hook must never break the user's session. +- Capture is gated by `HIVEMIND_CAPTURE !== "false"`. When that flag is set, the hook runs read-only: no DDL, no INSERTs. +- User-facing notices go through the SessionStart banner channel. Hooks never write error text into `additionalContext`, because arbitrary text in context is a prompt-injection risk. +- Each INSERT writes exactly one row per event, never concatenating events into a shared row, to prevent write races. +- All writes use the immutable version-bumped pattern for rules, skills, goals, and KPIs to avoid Deeplake's UPDATE-coalescing quirk. diff --git a/library/knowledge/private/plugins/mcp-and-extension-surfaces.md b/library/knowledge/private/plugins/mcp-and-extension-surfaces.md new file mode 100644 index 00000000..d13d2e00 --- /dev/null +++ b/library/knowledge/private/plugins/mcp-and-extension-surfaces.md @@ -0,0 +1,172 @@ +# MCP and Extension Surfaces + +> Category: Plugins | Version: 1.0 | Date: June 2026 | Status: Active + +The three non-hook integration surfaces in Hivemind: the MCP server that exposes memory as MCP tools, the OpenClaw native extension with its contracted tools and memory-corpus federation, and the distributable plugin bundles for Claude Code and Cursor. + +**Related:** +- [`integration-model.md`](integration-model.md) +- [`hook-lifecycle.md`](hook-lifecycle.md) +- [`../ai/embeddings-retrieval.md`](../ai/embeddings-retrieval.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../architecture/session-lifecycle.md`](../architecture/session-lifecycle.md) +- [`../overview.md`](../overview.md) +- [`../../../../docs/ARCHITECTURE.md`](../../../../docs/ARCHITECTURE.md) + +--- + +## Overview + +Hook-based capture and VFS recall cover Claude Code, Codex, Cursor, Hermes, and pi through their respective `hooks.json` or extension entry points. Three additional surfaces exist for agents or runtimes that cannot or should not use hooks for recall: + +1. The **MCP server** (`src/mcp/server.ts`) provides a standard Model Context Protocol interface to Hivemind memory, currently used by Hermes and available to any future MCP-capable client. +2. The **OpenClaw extension** (`harnesses/openclaw/src/index.ts`) is a native plugin that integrates through OpenClaw's gateway contract, registering tools, commands, and a memory-corpus supplement instead of hook events. +3. The **distributable bundles** (`harnesses/claude-code/`, `cursor/`) are the packaged artifacts that ship to their respective marketplaces and hook registries, built from the shared core sources. + +--- + +## The MCP server + +The MCP server lives at `src/mcp/server.ts` and exposes three tools over stdio transport. It is spawned as a subprocess by the consuming MCP client (Hermes today) and runs as a read-only process. It never writes to Deeplake and never creates tables; table provisioning happens in the per-agent SessionStart hooks. + +```mermaid +sequenceDiagram + participant Client as MCP client (Hermes) + participant Server as hivemind-mcp (server.ts) + participant DL as Deeplake + + Client->>Server: spawn via stdio + Server->>Server: loadCredentials() + Client->>Server: tools/call hivemind_search { query } + Server->>DL: searchDeeplakeTables(memory + sessions) + DL-->>Server: matching rows + Server-->>Client: { content: [{ type: "text", text: snippets }] } + + Client->>Server: tools/call hivemind_read { path } + Server->>DL: SELECT WHERE path = ... + DL-->>Server: row content + Server-->>Client: { content: [{ type: "text", text: content }] } + + Client->>Server: tools/call hivemind_index { prefix? } + Server->>DL: SELECT path, description, project, last_update_date + DL-->>Server: summary rows + Server-->>Client: { content: [{ type: "text", text: tsv }] } +``` + +### Tools + +**`hivemind_search`** accepts a `query` string and an optional `limit` (1-50, default 10). It delegates to `searchDeeplakeTables` from `src/shell/grep-core.ts`, which runs a hybrid lexical-plus-semantic query across both the `memory` table (summaries) and the `sessions` table (raw turns). Results are returned as path-and-snippet pairs separated by horizontal rules. The tool description explicitly notes that different paths under `/summaries//` belong to different users and should not be merged. + +**`hivemind_read`** accepts a single `path` string starting with `/`. It resolves the target table from the path prefix: `/sessions/` paths query the `sessions` table and fetch the `message` column; all other paths query the `memory` table and fetch the `summary` column. A LIMIT of 200 rows prevents runaway reads on large JSONL session files. + +**`hivemind_index`** accepts an optional `prefix` (e.g. `/summaries/alice/`) and an optional `limit` (1-200, default 50). It queries the `memory` table for summary entries sorted by `last_update_date` descending and returns a TSV payload with `path`, `last_updated`, `project`, and `description` columns. This is the discovery entry point: call it to see what is in memory, then `hivemind_read` to fetch a specific session. + +### Authentication and error handling + +The server calls `loadCredentials` at the start of every tool invocation via `getContext()`. If credentials are missing, tools return a clear "not authenticated" message rather than crashing. On a fresh org where no session has ever run, the `memory` and `sessions` tables do not exist yet (they are created by the first SessionStart hook). A missing-table 400 error from Deeplake is caught and surfaced as a human-readable "memory is empty" hint rather than a raw API error (resolved in issue #252). + +--- + +## The OpenClaw extension + +OpenClaw loads plugins from `~/.openclaw/extensions/hivemind/dist/index.js`. The extension is built from `harnesses/openclaw/src/index.ts` and installed by `src/cli/install-openclaw.ts`. It follows OpenClaw's `definePluginEntry` contract: a synchronous `register(pluginApi)` function that must complete registration before returning, with all async work in a fire-and-forget IIFE. + +### Plugin manifest + +The OpenClaw plugin manifest at `harnesses/openclaw/openclaw.plugin.json` declares the extension's contract: + +```json +{ + "id": "hivemind", + "contracts": { + "tools": [ + "hivemind_search", "hivemind_read", "hivemind_index", + "hivemind_goal_add", "hivemind_kpi_add" + ], + "commands": [ + "hivemind_login", "hivemind_capture", "hivemind_whoami", + "hivemind_orgs", "hivemind_switch_org", + "hivemind_workspaces", "hivemind_switch_workspace", + "hivemind_setup", "hivemind_version", + "hivemind_update", "hivemind_autoupdate" + ], + "memoryCorpusSupplements": true + } +} +``` + +The `memoryCorpusSupplements: true` declaration tells OpenClaw's runtime that this plugin implements the `MemoryCorpusSupplement` interface. Other plugins that expose a generic `memory_search` tool can fan out queries to registered supplements, so memory-core users get Hivemind hits automatically without any extra wiring. + +### Registered agent tools + +The three recall tools (`hivemind_search`, `hivemind_read`, `hivemind_index`) mirror the MCP server tools but use the OpenClaw `AgentTool` interface and accept richer parameters. `hivemind_search` additionally supports `path`, `regex`, and `ignoreCase` fields. All three call the same `searchDeeplakeTables` and `readVirtualPathContent` functions from the shared core. + +Two write tools are also registered: + +- **`hivemind_goal_add`** creates a new goal row in the `hivemind_goals` table with `agent: "openclaw"` provenance. It mirrors the `hivemind goal add --agent capture` CLI path. +- **`hivemind_kpi_add`** creates a KPI row in the `hivemind_kpis` table linked to an existing goal by `goal_id`. + +### Commands + +Eleven slash commands are available: `/hivemind_login`, `/hivemind_capture`, `/hivemind_whoami`, `/hivemind_orgs`, `/hivemind_switch_org`, `/hivemind_workspaces`, `/hivemind_switch_workspace`, `/hivemind_setup`, `/hivemind_version`, `/hivemind_update`, and `/hivemind_autoupdate`. + +The `/hivemind_setup` command is OpenClaw-specific. It calls `ensureHivemindAllowlisted` from the dynamically-imported `setup-config.ts` chunk to add `"hivemind"` to both `plugins.allow` and `tools.alsoAllow` in `~/.openclaw/openclaw.json`. This step is required once per install to make the memory tools available to the active agent model. + +### The env-harvesting workaround + +OpenClaw's per-bundle static scanner flags any `process.env` access in a file that also calls `fetch()` as critical `env-harvesting`. To work around this, all `HIVEMIND_*` environment variable reads in the openclaw bundle are replaced by esbuild's `define` with `globalThis.__hivemind_tuning__?.HIVEMIND_X`. The `applyOpenclawTuning` function, called once at `register()` time, bridges the user's `openclaw.json` plugin config `tuning` object into that global: + +``` +pluginConfig.tuning.queryTimeoutMs → globalThis.__hivemind_tuning__.HIVEMIND_QUERY_TIMEOUT_MS +pluginConfig.tuning.semanticSearch → globalThis.__hivemind_tuning__.HIVEMIND_SEMANTIC_SEARCH +pluginConfig.tuning.debug → globalThis.__hivemind_tuning__.HIVEMIND_DEBUG +... +``` + +This preserves runtime tunability through the plugin config without any literal `process.env.X` strings in the bundle. + +### Capture and skillify in agent_end + +The `agent_end` hook fires after each agent turn with a full `messages` array. The extension slices off the messages captured since the previous turn using a per-session count (`capturedCounts`), inserts each new user/assistant message as a separate row into the `sessions` table with a session path matching the Claude Code convention (`/sessions//___.jsonl`), and then fires the skillify worker. + +Because OpenClaw has no CLI of its own, the skillify worker needs a delegate gate agent to evaluate whether a session is worth mining. `detectOpenclawGateAgent` probes the PATH for `claude`, `codex`, `cursor-agent`, `hermes`, and `pi` in that order and returns the first found. If none is installed, the worker spawn is skipped for that turn. + +### Memory-corpus supplement + +The extension registers a `MemoryCorpusSupplement` object with `search` and `get` methods. Both call the same Deeplake query functions used by the agent-facing tools. The `search` method scores summary hits (`/summaries/` paths) at 0.8 and raw session hits at 0.6, spreading within-group by source order so results stay deterministic across runs. + +--- + +## Distributable plugin bundles + +### Claude Code marketplace plugin + +The Claude Code integration ships as a marketplace plugin from the `harnesses/claude-code/` directory. The build step (`npm run build`) runs `tsc` then `esbuild` and emits the bundle to `harnesses/claude-code/bundle/`. The plugin is submitted to the Claude Code Marketplace and installed by users via `hivemind install` or by running the marketplace installer directly. + +The plugin manifest specifies the hook entry points and declares the Claude Code-specific capabilities (tool permissions, capabilities flags). The actual hook logic lives in `src/hooks/` and is bundled into the plugin output. + +### 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 `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. + +### Build outputs and distribution channels + +```mermaid +flowchart LR + src["src/hooks/\nsrc/mcp/\nharnesses/openclaw/src/"] -- tsc + esbuild --> bundles + + subgraph bundles["Build outputs"] + ccBundle["harnesses/claude-code/bundle/\n→ Claude Code Marketplace"] + codexBundle["harnesses/codex/bundle/\n→ npm @deeplake/hivemind"] + 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"] + piSource["harnesses/pi/extension-source/\n→ npm (raw .ts, pi compiles)"] + cliBundle["bundle/cli.js\n→ npm @deeplake/hivemind"] + end +``` + +All npm-distributed artifacts ship as part of the `@deeplake/hivemind` package. The `hivemind install` CLI detects which assistants are installed on the machine and writes the appropriate config files and bundle symlinks for each one. The OpenClaw plugin is distributed separately through ClawHub, OpenClaw's native plugin registry, because its bundle must pass ClawHub's static security scanner and satisfy the native extension contract. diff --git a/library/knowledge/private/reports/2026-06-12-reverse-document-qa-report.md b/library/knowledge/private/reports/2026-06-12-reverse-document-qa-report.md new file mode 100644 index 00000000..d1a4ad30 --- /dev/null +++ b/library/knowledge/private/reports/2026-06-12-reverse-document-qa-report.md @@ -0,0 +1,138 @@ +# QA Report: Hivemind Reverse-Documentation Audit + +**Plan document:** reverse-document worktree spec (inline plan, no PRD file) +**Audit date:** 2026-06-12 +**Worktree:** `/home/marioaldayuz/Desktop/GitHub/hivemind-doc-reverse-document` +**Auditor:** quality-guardian + +--- + +## Summary + +**Pass-with-warnings (all warnings remediated in-session).** All 22 expected deliverables are present and structurally complete. The implementation meets the plan's requirements for headers, Related sections, cross-links, overview reading guide, narrative quality, code grounding, and domain exclusions. One Warning was found and fixed: three docs (`auth/auth-architecture.md`, `security/credential-storage.md`, `security/trust-boundaries.md`) used numbered section headings inconsistent with the other 19 docs; all have been corrected. After remediation, no medium-or-higher findings remain. + +--- + +## Scorecard + +| Category | Status | Notes | +|--------------|--------|-------| +| Completeness | ✅ | All 22 docs present; all 11 domains covered; overview reading guide complete | +| Correctness | ✅ | Headers, metadata blocks, Related sections, and cross-link depths are correct throughout | +| Alignment | ✅ | No excluded domains (curriculum, container, monetization) found; narrative grounded in actual source files | +| Gaps | ✅ | No plan item missing; all cross-links present in Related sections | +| Detrimental | ✅ | Three numbered-heading docs fixed in-session; no other detrimental patterns remain | + +--- + +## Critical Issues (must fix) + +None. + +--- + +## Warnings (should fix) + +- [x] **Numbered section headings in auth and security docs** - `auth/auth-architecture.md:17-147`, `security/credential-storage.md:16-123`, `security/trust-boundaries.md:16-165` + + Three docs used numbered heading syntax (`## Section 1 - Why this exists`, `## Section 2 - ...`) while the other 19 docs consistently use descriptive non-numbered headings (`## Why this exists`, `## Device Authorization Flow`, etc.). This inconsistency disrupts the reader experience when navigating across the knowledge base and is a maintenance hazard - the numbers become stale when sections are added or reordered. + + **Before (all three docs):** + ```markdown + ## Section 1 - Why this exists + ## Section 2 - Device Authorization Flow + ## Section 3 - Org Selection Priority + ``` + + **After (fixed in-session):** + ```markdown + ## Why this exists + ## Device Authorization Flow + ## Org Selection Priority + ``` + + All `Section N -` prefixes removed from all three files. Verified by re-reading each file after edit. + +--- + +## Suggestions (consider improving) + +- [ ] **Cursor IDE citation syntax in monorepo-build-release.md** - `infrastructure/monorepo-build-release.md:31-238` + + The doc uses the Cursor IDE code-reference fenced block format (`\`\`\`33:50:package.json`) to cite file regions. Standard markdown renderers display this as a code block with the full `33:50:package.json` string as the language tag, which is not a recognized syntax highlighter. Other knowledge docs cite source files by inline `code` reference or show representative snippets with a comment. Consider replacing with a plain code block plus a filename comment, or an inline reference to the file path. + +- [ ] **frontend/cursor-extension-architecture.md missing back-link to overview** - `frontend/cursor-extension-architecture.md:8-13` + + The Related section does not link back to `../overview.md`, while every other domain doc (17 of 18 non-overview docs checked) includes the overview as a Related entry. Adding it keeps navigation from the overview back-link discoverable in both directions. + + ```markdown + **Related:** + - [`../overview.md`](../overview.md) ← add this line + - [`../plugins/integration-model.md`](../plugins/integration-model.md) + ``` + +- [ ] **infrastructure/monorepo-build-release.md missing cross-links to ai/ and data/** - `infrastructure/monorepo-build-release.md:8-12` + + The build doc covers the embed daemon bundle (`embeddings/` output) and tree-sitter graph extraction, both of which have dedicated knowledge docs. Adding `../ai/embeddings-retrieval.md` and `../data/codebase-graph.md` to the Related section would help readers navigating from build questions to runtime architecture questions. + +--- + +## Plan Item Traceability + +| # | Plan Requirement | Status | Implementation Location | Notes | +|---|-----------------|--------|------------------------|-------| +| 1 | `overview.md` present | ✅ | `overview.md` | H1 header, metadata block, reading guide, coverage stats | +| 2 | `architecture/system-overview.md` present | ✅ | `architecture/system-overview.md` | Mermaid subsystem diagram, integration table, 1 Related block | +| 3 | `architecture/session-lifecycle.md` present | ✅ | `architecture/session-lifecycle.md` | Full Mermaid sequence diagram, all phases documented | +| 4 | `plugins/` - 3 docs | ✅ | `plugins/integration-model.md`, `hook-lifecycle.md`, `mcp-and-extension-surfaces.md` | All 3 present with Related sections | +| 5 | `ai/` - 4 docs | ✅ | `ai/session-capture.md`, `ai/wiki-summary-workers.md`, `ai/skillify-pipeline.md`, `ai/embeddings-retrieval.md` | All 4 present | +| 6 | `data/` - 3 docs | ✅ | `data/deeplake-tables-schema.md`, `data/memory-virtual-filesystem.md`, `data/codebase-graph.md` | All 3 present | +| 7 | `auth/` - 1 doc | ✅ | `auth/auth-architecture.md` | Present; numbered headings fixed (Warning W1) | +| 8 | `security/` - 2 docs | ✅ | `security/credential-storage.md`, `security/trust-boundaries.md` | Both present; numbered headings fixed (Warning W1) | +| 9 | `infrastructure/` - 1 doc | ✅ | `infrastructure/monorepo-build-release.md` | Present; Cursor citation format noted as Suggestion | +| 10 | `operations/` - 2 docs | ✅ | `operations/cli-command-architecture.md`, `operations/notifications-and-health.md` | Both present | +| 11 | `frontend/` - 1 doc | ✅ | `frontend/cursor-extension-architecture.md` | Present; missing overview back-link noted as Suggestion | +| 12 | `multi-tenant/` - 1 doc | ✅ | `multi-tenant/org-workspace-model.md` | Present | +| 13 | `collaboration/` - 1 doc | ✅ | `collaboration/team-skills-sharing.md` | Present | +| 14 | All docs have H1 header | ✅ | All 22 files | Consistent `# Title` H1 throughout | +| 15 | All docs have metadata block | ✅ | All 22 files | `> Category: X \| Version: 1.0 \| Date: June 2026 \| Status: Active` | +| 16 | All docs have Related section | ✅ | All 22 files | Bold **Related:** with markdown links | +| 17 | Cross-links correct depth | ✅ | Spot-checked 12 cross-links | Relative depths verified for `../../`, `../../../`, `../../../../` | +| 18 | Overview has reading guide | ✅ | `overview.md:62-145` | Full per-domain table + task-oriented "Where to start" section | +| 19 | Narrative quality - "why" sections | ✅ | All 22 files | Every doc opens with a "Why this exists/looks like this" section | +| 20 | Code grounding - file/function citations | ✅ | All 22 files | `src/hooks/capture.ts`, `src/deeplake-schema.ts`, etc. cited throughout; code snippets included | +| 21 | No excluded domain: curriculum | ✅ | All 22 files | Not mentioned anywhere | +| 22 | No excluded domain: container | ✅ | All 22 files | Not mentioned anywhere | +| 23 | No excluded domain: monetization | ✅ | All 22 files | BYOC covered purely as storage boundary, not pricing/monetization | + +--- + +## Files Changed + +All 22 knowledge docs audited. Fixes applied to 3 files: + +- `auth/auth-architecture.md` (M) - Removed `Section N -` prefix from all 8 H2 headings +- `security/credential-storage.md` (M) - Removed `Section N -` prefix from all 7 H2 headings +- `security/trust-boundaries.md` (M) - Removed `Section N -` prefix from all 9 H2 headings + +Files audited but not modified (all passed): + +- `overview.md` +- `architecture/session-lifecycle.md` +- `architecture/system-overview.md` +- `ai/embeddings-retrieval.md` +- `ai/session-capture.md` +- `ai/skillify-pipeline.md` +- `ai/wiki-summary-workers.md` +- `collaboration/team-skills-sharing.md` +- `data/codebase-graph.md` +- `data/deeplake-tables-schema.md` +- `data/memory-virtual-filesystem.md` +- `frontend/cursor-extension-architecture.md` +- `infrastructure/monorepo-build-release.md` +- `multi-tenant/org-workspace-model.md` +- `operations/cli-command-architecture.md` +- `operations/notifications-and-health.md` +- `plugins/hook-lifecycle.md` +- `plugins/integration-model.md` +- `plugins/mcp-and-extension-surfaces.md` diff --git a/library/knowledge/private/security/credential-storage.md b/library/knowledge/private/security/credential-storage.md new file mode 100644 index 00000000..f63b53d9 --- /dev/null +++ b/library/knowledge/private/security/credential-storage.md @@ -0,0 +1,122 @@ +# Credential Storage + +> Category: Security | Version: 1.0 | Date: June 2026 | Status: Active + +Documents where Hivemind stores credentials on disk, the file-system permissions enforced on every write, the shape of the credentials object, and the three file-IO helpers that own all access to the credentials file. + +**Related:** +- [`trust-boundaries.md`](trust-boundaries.md) +- [`../auth/auth-architecture.md`](../auth/auth-architecture.md) +- [`../multi-tenant/org-workspace-model.md`](../multi-tenant/org-workspace-model.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../operations/cli-command-architecture.md`](../operations/cli-command-architecture.md) +- [`../overview.md`](../overview.md) + +--- + +## Why this exists + +Credentials (access token, org identity, workspace selection) must persist across processes and restarts without relying on a running daemon. A single JSON file under the user's home directory satisfies this requirement and is the conventional pattern for developer tools on all three supported platforms (macOS, Linux, Windows). + +No keychain, secret manager, or OS credential store is used. The security model relies entirely on file-system permissions: only the owning user can read or write the credentials file. + +--- + +## File Paths + +Both path accessors are defined in `src/commands/auth-creds.ts` and are lazy (re-evaluated on each call, not bound at module load time): + +| Name | Value | Notes | +|---|---|---| +| `configDir()` | `~/.deeplake` | Parent directory. Resolved via `homedir()` at call time. | +| `credsPath()` | `~/.deeplake/credentials.json` | Full path to the credentials file. | + +The lazy evaluation is deliberate: tests can override `HOME` (via `process.env.HOME`) between test cases without needing to re-import the module. At module-load time `homedir()` would capture the value once, making `HOME` overrides invisible to subsequent calls. + +--- + +## File-System Permissions + +`saveCredentials()` enforces permissions on every write: + +| Resource | Mode | Who can access | +|---|---|---| +| `~/.deeplake/` (directory) | `0700` (`rwx------`) | Owning user only | +| `~/.deeplake/credentials.json` | `0600` (`rw-------`) | Owning user only | + +The directory is created with `mkdirSync({ recursive: true, mode: 0o700 })`. The `recursive: true` flag is idempotent: if the directory already exists, the call is a no-op and does NOT change the existing mode. Mode `0o700` is applied only on initial creation. + +The file is written with `writeFileSync(path, json, { mode: 0o600 })`. On POSIX systems this sets the permission bits directly. On Windows, the mode parameter is silently ignored; Windows access control relies on the user profile directory being protected at the OS level. + +--- + +## Credentials Schema + +The `Credentials` interface (TypeScript source of truth in `src/commands/auth-creds.ts`): + +| Field | Type | Required | Description | +|---|---|---|---| +| `token` | `string` | yes | Long-lived org-bound JWT (365-day expiry). Bearer token for all API calls. | +| `orgId` | `string` | yes | Active organization ID. Must match `org_id` claim in `token`. | +| `orgName` | `string` | no | Human-readable org name. Used for display only (e.g. session banner). | +| `userName` | `string` | no | Display name fetched from `GET /me` at login time. | +| `workspaceId` | `string` | no | Active workspace. Defaults to `"default"` (the backend resolves the sentinel). | +| `apiUrl` | `string` | no | Base URL for the Deeplake API. Defaults to `https://api.deeplake.ai` when absent. | +| `autoupdate` | `boolean` | no | Whether the plugin self-updates on session start. Absent = `true`. | +| `savedAt` | `string` | yes | ISO 8601 timestamp written by `saveCredentials`. Used for auditing; not validated at load time. | + +Example file contents: + +```json +{ + "token": "eyJ...", + "orgId": "acme-inc", + "orgName": "Acme Inc", + "userName": "alice", + "workspaceId": "default", + "apiUrl": "https://api.deeplake.ai", + "autoupdate": true, + "savedAt": "2026-06-12T23:00:00.000Z" +} +``` + +--- + +## File IO Helpers + +Three functions in `src/commands/auth-creds.ts` own all disk access. No other module reads or writes the credentials file directly. + +### `loadCredentials(): Credentials | null` + +Reads and JSON-parses `~/.deeplake/credentials.json`. Returns `null` for any failure: missing file (`ENOENT`), permission denied, or malformed JSON. Callers treat `null` as "not logged in" and prompt the user to run `hivemind login`. The anti-pattern of `existsSync` followed by `readFileSync` is deliberately avoided; it introduces a TOCTOU race and extra branches with no safety benefit. + +### `saveCredentials(creds: Credentials): void` + +Writes credentials to disk. Always: +1. Calls `mkdirSync(configDir(), { recursive: true, mode: 0o700 })` to ensure the directory exists. +2. Calls `writeFileSync(credsPath(), JSON.stringify({...creds, savedAt: now}, null, 2), { mode: 0o600 })`. +3. Overwrites `savedAt` with the current timestamp regardless of what was passed in. + +The function never throws on success; failures (e.g. permission denied) surface as Node `fs` exceptions to the caller. + +### `deleteCredentials(): boolean` + +Calls `unlinkSync(credsPath())`. Returns `true` if the file was removed, `false` for any failure (file already gone, permission denied, EBUSY). The `logout` command displays "Not logged in." on `false`, not an error, because the end state (no credentials file) is identical regardless of whether the file existed. + +--- + +## No Keychain Integration + +Hivemind does not use any OS keychain or secret manager (Keychain Access on macOS, libsecret/gnome-keyring on Linux, Windows Credential Manager). The decision prioritizes cross-platform consistency and zero-dependency credential access inside bundled Node scripts: keychains require native bindings that would complicate the esbuild bundle and break in some CI environments. + +The tradeoff is that `~/.deeplake/credentials.json` is readable by any process running as the same OS user. The mitigations are: + +- File mode `0600` prevents other OS users from reading the file. +- The token is org-bound and carries a 365-day expiry. Rotating it is a single `hivemind login` command. +- `HIVEMIND_TOKEN` environment variable overrides the file entirely for short-lived CI contexts where no persistent credential is appropriate. + +--- + +## Module Isolation Contract + +`src/commands/auth-creds.ts` is intentionally kept free of any `fetch` calls. The module-level file header documents this: it exists so bundlers (particularly the openclaw plugin's esbuild config) can enforce per-file static-analysis rules that flag co-occurrence of `fs` reads/writes with network calls. Keeping IO and network in separate source files is an explicit architectural constraint, not an accident. diff --git a/library/knowledge/private/security/trust-boundaries.md b/library/knowledge/private/security/trust-boundaries.md new file mode 100644 index 00000000..af52576c --- /dev/null +++ b/library/knowledge/private/security/trust-boundaries.md @@ -0,0 +1,165 @@ +# Trust Boundaries + +> Category: Security | Version: 1.0 | Date: June 2026 | Status: Active + +Maps every trust boundary in the Hivemind system: where code runs, what it can access, who controls each boundary, and what defenses prevent privilege escalation or data leakage between zones. + +**Related:** +- [`credential-storage.md`](credential-storage.md) +- [`../auth/auth-architecture.md`](../auth/auth-architecture.md) +- [`../data/memory-virtual-filesystem.md`](../data/memory-virtual-filesystem.md) +- [`../architecture/system-overview.md`](../architecture/system-overview.md) +- [`../overview.md`](../overview.md) + +--- + +## Trust Boundary Map + +```mermaid +flowchart TD + userBrowser([User Browser]) + agentProcess([Coding Agent Process]) + hookProcess([Hook / Plugin Process]) + hivemindPlugin([Hivemind Plugin Code]) + credFile([~/.deeplake/credentials.json]) + memoryVFS([~/.deeplake/memory - VFS]) + deeplakeAPI([Deeplake API - api.deeplake.ai]) + deeplakeTenant([Tenant Storage - org-isolated]) + byocBucket([BYOC Bucket - GCS / Azure / S3]) + + userBrowser -- "OAuth approval - HTTPS" --> deeplakeAPI + agentProcess -- "spawns hooks - same user" --> hookProcess + hookProcess -- "imports / runs" --> hivemindPlugin + hivemindPlugin -- "read 0600 file" --> credFile + hivemindPlugin -- "VFS intercept - SQL-backed" --> memoryVFS + hivemindPlugin -- "Bearer JWT - TLS" --> deeplakeAPI + deeplakeAPI -- "org-scoped storage" --> deeplakeTenant + deeplakeTenant -- "GCS/Azure/S3 creds in vault" --> byocBucket + + style credFile fill:#f9f9f9 + style byocBucket fill:#f9f9f9 +``` + +Note: the diagram uses fill only on data-at-rest nodes to distinguish them from process nodes; no security meaning is implied by the fill color. + +--- + +## Zone Definitions + +| Zone | Owner | What runs there | Trust level | +|---|---|---|---| +| **User Browser** | User's OS | OAuth device-flow approval page | User-trusted (separate from agent) | +| **Agent Process** | Coding agent (Claude Code, Codex, Cursor, etc.) | Agent LLM loop, tool calls | Host OS user | +| **Hook / Plugin Process** | Agent runtime | Spawned Node bundles at lifecycle events | Same OS user as agent | +| **Hivemind Plugin Code** | `~/.cursor/hivemind/bundle/` or equivalent | Capture, recall, session hooks | Same OS user; no elevated privilege | +| **Credentials File** | File system | `~/.deeplake/credentials.json` | Mode 0600; OS user only | +| **VFS Layer** | `~/.deeplake/memory/` | SQL-backed virtual filesystem for memory | OS user; allowlisted commands only | +| **Deeplake API** | Activeloop / cloud | REST API, session storage, skill mining | Authenticated with org-bound JWT | +| **Tenant Storage** | Deeplake cloud | Row-level org/workspace isolation | Server-enforced; AES-256 at rest | +| **BYOC Bucket** | Customer's cloud (GCS/Azure/S3) | Raw object storage | Customer-controlled; creds in Deeplake vault | + +--- + +## Token Handling at Boundaries + +The access token is the primary security primitive. It moves across boundaries as follows: + +```mermaid +sequenceDiagram + autonumber + participant disk as Credentials File + participant hook as Hook Process + participant api as Deeplake API + + hook->>disk: loadCredentials() - read 0600 file + disk-->>hook: { token, orgId, ... } + hook->>api: Authorization: Bearer + X-Activeloop-Org-Id: (TLS) + api-->>hook: response scoped to orgId + Note over hook: token never written to stdout/stdin + Note over hook: token never set in child process env +``` + +Key invariants: +- The token is read from disk at hook startup. It is never passed as a command-line argument (visible in `ps aux`) or written to `process.env` (visible to child processes). +- All API calls use TLS. The token is in an HTTP header, not a URL query parameter. +- `authLog` writes to `process.stderr`, not `stdout`, so token-adjacent messages cannot be parsed by callers that read hook stdout as structured data. + +--- + +## Hook Consent Model + +Hivemind installs hooks into agent lifecycle events (`sessionStart`, `beforeSubmitPrompt`, `postToolUse`, `afterAgentResponse`, `stop`, `sessionEnd`). Each agent platform enforces its own consent model before running foreign hooks: + +| Platform | Consent mechanism | +|---|---| +| **Codex** | "Hooks need review" terminal prompt on first run. User must choose "Trust all and continue"; otherwise hooks are inert. | +| **Cursor** | `hooks.json` is written to `~/.cursor/hooks.json`. Cursor 1.7+ reads this file; the user controls the Cursor installation. | +| **Claude Code** | Plugin marketplace install; Claude Code's own approval flow for marketplace plugins. | +| **OpenClaw** | `openclaw plugins install clawhub:hivemind`; ClawHub approval. | +| **Hermes** | `config.yaml` hooks section; operator-controlled config file. | +| **pi** | `AGENTS.md` marker block + TypeScript extension; user controls the `~/.pi/agent/` directory. | + +In all cases, no hook runs silently without an explicit user action. The install command (`hivemind install`) displays a one-line consent notice before opening the browser for authentication. + +--- + +## VFS Allowlist + +The virtual filesystem intercepts reads and writes to `~/.deeplake/memory/`. Commands routed through this layer are matched against an allowlist of approximately 70 built-in operations. Any command not on the allowlist is denied with an error. This prevents an agent from using the VFS path to execute arbitrary shell commands under the guise of memory operations. + +SQL values passed into VFS-backed queries are escaped through three utility functions: +- `sqlStr(value)` - safe string literal +- `sqlLike(value)` - safe LIKE pattern +- `sqlIdent(value)` - safe identifier (table/column name) + +These prevent SQL injection from agent-provided values such as memory keys or search terms. + +--- + +## Org and Workspace Isolation + +Deeplake enforces multi-tenant isolation at the storage layer, not only at the API layer: + +- Sessions never share a row, partition, or index with another workspace. +- The `X-Activeloop-Org-Id` header passed with every API call is validated server-side against the `org_id` claim in the JWT. A token minted for org A cannot be used to read org B data by spoofing the header. +- Hivemind's credential store mirrors this: `creds.orgId` and the `org_id` JWT claim are kept in sync by `healDriftedOrgToken`. A session that starts with a drifted token (claim and stored ID disagree) has its token reminted before any API call is made. + +--- + +## Bring Your Own Cloud (BYOC) + +BYOC moves object storage into the customer's own cloud account while leaving orchestration with Activeloop. + +| Provider | Status | Boundary | +|---|---|---| +| Google Cloud Storage | Available | Customer GCS bucket; Activeloop reads/writes via GCS credentials stored in Deeplake vault | +| Azure Blob Storage | Available | Customer Azure container; same vault model | +| Amazon S3 | Available | Customer S3 bucket | +| S3-compatible on-prem | On request | Customer network; requires private network or VPN | + +In all BYOC configurations, Hivemind (the client-side plugin) is unaware of the storage backend. The plugin calls `api.deeplake.ai` over TLS; the backend handles storage routing. The raw cloud provider credentials (GCS service account key, Azure SAS token, AWS credentials) are stored in Deeplake's vault and are never transmitted to the plugin process. Hivemind never sees the raw keys. + +--- + +## Capture Opt-Out + +The `HIVEMIND_CAPTURE=false` environment variable places Hivemind in read-only mode. In this mode: +- Session capture hooks execute but skip writing any trace data. +- The DDL ensure step (which writes placeholder rows) is also skipped. +- Recall and search still function. + +This provides a per-session escape hatch for sensitive workflows where trace capture is inappropriate (e.g. working with credentials, PII-heavy files, or regulated data). + +--- + +## Data Classification Summary + +| Data type | Where stored | At rest | In transit | Access scope | +|---|---|---|---|---| +| Access token | `~/.deeplake/credentials.json` | Plaintext; mode 0600 | Bearer header over TLS | OS user only | +| Session traces (prompts, tool calls, responses) | Deeplake tenant storage | AES-256 | TLS | All members of the org workspace | +| Codified skills (`SKILL.md`) | Project directory + Deeplake | Plaintext files + AES-256 | TLS | Org workspace members | +| Memory summaries | Deeplake `memory` table | AES-256 | TLS | Org workspace members | +| BYOC cloud credentials | Deeplake vault | Encrypted | Never sent to plugin | Deeplake backend only | + +The data collection notice in README.md states explicitly: "All users in your Deeplake workspace can read this data." Workspace-level isolation is the outer boundary; within a workspace, all members share the trace and skill surface by design. diff --git a/library/knowledge/private/standards/documentation-framework.md b/library/knowledge/private/standards/documentation-framework.md new file mode 100644 index 00000000..b67f1ed2 --- /dev/null +++ b/library/knowledge/private/standards/documentation-framework.md @@ -0,0 +1,154 @@ +# Documentation Framework + +> Category: Standards | Version: 1.0 | Date: (fill in on init) | Status: Canonical + +The single source of truth for how documentation is written in this repository. Every document — feature PRDs, issue PRDs, QA reports, architecture docs, API references, guides — must conform to the standards defined here. If a document type is not covered, add a new section to this file rather than inventing a local convention. + +--- + +## 1. Document Types + +| Type | Purpose | Location | Primary audience | +|---|---|---|---| +| **Issue IRD** | Implementation plan for a specific GitHub issue | `library/requirements/issues/issue-<###>-/ird-issue-<###>-<title>.md` | Implementation engineer | +| **Feature PRD** | Planned feature spec (forward or retroactive) | `library/requirements/features/feature-<###>-<title>/prd-feature-<###>-<title>.md` (or `prd-feature-<###>-<title>-ck-<clickupId>.md` if from ClickUp) | Implementation engineer | +| **QA Report (tied)** | Audit of an implementation against its plan | The plan's own `reports/<date>-qa-report.md` subfolder | Team lead, author of the feature | +| **QA Report (standalone)** | Audit not tied to a single plan | `library/qa/<domain>/<date>-qa-report.md` | Team lead, audit reviewer | +| **Architecture Doc** | System design, data flows, component relationships | `library/knowledge-base/architecture/` | Senior engineers, architects | +| **API Reference** | Endpoint-by-endpoint documentation with schemas | `library/knowledge-base/api/` | Frontend devs, API consumers | +| **How-to Guide** | Runbooks for setup, testing, deploying, adding features | `library/knowledge-base/how-to-guides/` | New engineers, DevOps | +| **Integration Doc** | Third-party service configuration and error handling | `library/knowledge-base/integrations/` | DevOps, engineers wiring services | +| **UX/UI Standard** | Visual design language — tokens, components, patterns | `library/knowledge-base/design/` | Designers, frontend devs | +| **Feature Doc** | Completed feature reference (post-ship) | `library/knowledge-base/features/` | Any engineer joining the project | +| **Spec** | Feature-level handoff spec for a UI flow | `library/knowledge-base/specs/` | Frontend engineers | +| **Product Brief** | Product vision, scope, roadmap | `library/knowledge-base/product/` | Team, stakeholders | +| **Standards Doc** | Rules for writing documentation itself | `library/knowledge-base/standards/` | All contributors | +| **Release Notes** | What changed in each release | `library/knowledge-base/releases/` | All team members | + +--- + +## 2. Universal Document Header + +Every markdown file under `library/knowledge-base/` starts with: + +```markdown +# <Document Title> + +> Category: <Type> | Version: <X.Y> | Date: <Month YYYY> | Status: <Active | Draft | Archived> + +<One-sentence description of what this document covers and who should read it.> + +**Related:** +- [Link to related doc] +- [Link to source code: `src/path/to/file.ts`] +``` + +- **Version** — starts at `1.0`; patch bumps (`1.0` → `1.1`) for additions, minor bumps (`1.x` → `2.0`) for reorganizations. +- **Date** — current month/year on the last meaningful edit. +- **Status** values: + - `Active` — current, should be kept up to date + - `Draft` — work in progress, not authoritative + - `Archived` — historical, no longer maintained + - `Canonical` — (for standards docs only) highest authority; overrides ad-hoc conventions + +Requirements-type docs (issue IRDs, feature PRDs, QA reports) use a different header format documented in their respective guides. + +--- + +## 3. Filename Conventions + +| Document type | Folder + filename pattern | Example | +|---|---|---| +| Issue IRD | `issue-<###>-<title>/ird-issue-<###>-<title>.md` (with sibling `reports/`) | `issue-046-stale-cached-responses/ird-issue-046-stale-cached-responses.md` | +| Feature PRD | `feature-<###>-<title>/prd-feature-<###>-<title>.md` (with sibling `reports/`) | `feature-007-user-profile-export/prd-feature-007-user-profile-export.md` | +| Feature PRD (from ClickUp) | `feature-<###>-<title>/prd-feature-<###>-<title>-ck-<clickupId>.md` | `feature-007-user-profile-export/prd-feature-007-user-profile-export-ck-86c8wq2k1.md` | +| QA report (tied to plan) | `<plan-folder>/reports/<date>-qa-report.md` | `feature-007-user-profile-export/reports/2026-04-26-qa-report.md` | +| QA report (standalone) | `library/qa/<domain>/<date>-qa-report.md` | `library/qa/auth/2026-04-26-qa-report.md` | +| Knowledge-base | `<domain>/<kebab-slug>.md` (no numeric prefix) | `architecture/authentication-flow.md` | + +**Numbering rules:** +- `<###>` is **3-digit zero-padded** (`006`, `046`, `093`, `100`). 4+ digit natural width. +- Issue numbers follow the GitHub issue number. +- Feature numbers are repo-local sequential; take `max + 1` from existing folders (open + `completed/`). +- Titles are lowercase kebab-case, ≤60 chars. +- The optional ClickUp suffix `-ck-<clickupId>` goes on the **main file only**, never on the folder name. + +--- + +## 4. Folder Location Rules + +| Folder | Meaning | +|---|---| +| `library/requirements/features/feature-<###>-<title>/` | Feature work in progress. | +| `library/requirements/features/completed/feature-<###>-<title>/` | Feature has shipped. Move the entire folder (PRD + `reports/`). | +| `library/requirements/issues/issue-<###>-<title>/` | Issue work in progress (GitHub issue OPEN). | +| `library/requirements/issues/completed/issue-<###>-<title>/` | Issue has been resolved (GitHub issue CLOSED). Move the entire folder (IRD + `reports/`). Symmetric to features. | +| `<plan-folder>/reports/` | QA reports tied to that specific feature/issue. Travel with the folder when it moves. | +| `library/qa/<domain>/` | Standalone QA reports — broad audits not tied to a single plan. | + +Move folders when status changes. Never edit lifecycle state in frontmatter alone. + +--- + +## 5. Writing Rules (all doc types) + +1. **Ground every claim in code.** Quote source with file path + line range; never paraphrase signatures. +2. **One topic per document.** Split if a doc exceeds ~500 lines. +3. **Progressive disclosure.** Open with "why this exists" and "who should read it"; deep details below. +4. **Link out, don't duplicate.** If another doc covers a subtopic, link to it. +5. **Diagrams use mermaid.** Prefer `flowchart TD` or `sequenceDiagram`. No explicit colors. +6. **No time-sensitive language.** Avoid "currently", "recently", "as of". Use explicit dates. +7. **No personal opinions.** Docs describe decisions and rationale, not preferences. + +--- + +## 6. Cross-Linking Conventions + +- Use relative paths: `[title](../relative/path.md)`. +- Link to code with file paths (and line numbers where useful): `` `src/routes/users.ts:42-80` ``. +- PRDs and IRDs link to their related issues, features, and QA reports in a **Related** section at the end. +- Knowledge-base docs link to the PRDs that drove them (when applicable) and to source code. + +--- + +## 7. Diagram Rules + +- Mermaid preferred (renders everywhere GitHub does). +- Use `flowchart TD` (top-down) for process flows; `sequenceDiagram` for temporal flows; `erDiagram` for data models. +- Node IDs: no spaces (use `camelCase` or `under_scores`). +- No explicit colors (breaks dark mode). +- No `click` events. +- Quote labels containing parentheses, brackets, or colons. + +--- + +## 8. Versioning + Dates + +- **Versioning** is per-document, not repo-wide. Bump on meaningful content change. +- **Dates** use the current month/year (from the system clock), not arbitrary timestamps. +- Each document optionally ends with a **Changelog** section listing version bumps. + +--- + +## 9. Ownership + +- Requirements docs (issue IRDs, feature PRDs) are owned by the implementation author. QA reports are owned by `quality-guardian`. +- Knowledge-base docs are owned by the team collectively — anyone may edit with a PR. +- Standards docs (this file included) require team consensus before changing. + +--- + +## 10. Bootstrap — After `initialize` + +When `library-guardian initialize` seeds a repo: + +1. Replace the placeholder "(fill in on init)" in the header above with the current month/year. +2. Replace any project-name placeholders in the seeded README files with your repo's actual name. +3. Edit any section of this framework that doesn't match your team's conventions — then commit. +4. Start using the agent: ingest issues, plan features, document architecture. + +--- + +## Changelog + +- v1.0 — Initial template seeded by `library-guardian`. Customize per repo. diff --git a/library/knowledge/public/README.md b/library/knowledge/public/README.md new file mode 100644 index 00000000..d37f73eb --- /dev/null +++ b/library/knowledge/public/README.md @@ -0,0 +1,39 @@ +--- +ai_description: | + This folder contains customer-facing / end-user documentation. + Approved sub-folders: overview/, guides/, faqs/, and any domain + folder explicitly designated public by the team. + Do NOT file internal engineering docs, ADRs, pricing strategy, or + security-sensitive material here. + Write path: library/knowledge/public/<domain>/<kebab-slug>.md. + All files here may eventually be surfaced in the public help center + (Phase 2). Mark each doc with the standard knowledge-base header: + Category / Version / Date / Status. +human_description: | + Customer-facing documentation. Content here may be published externally. + - overview/: what this product is, glossary, elevator pitch + - guides/: how-to guides written for users, not developers + - faqs/: frequently asked questions + Only add content here that you are comfortable sharing publicly. + Internal notes, pricing strategy, and architecture docs belong in + knowledge/private/ instead. +--- + +# Knowledge — Public + +Customer-facing documentation. Anything in this folder may eventually be published. + +## Approved sub-folders + +| Folder | Contents | +|---|---| +| `overview/` | What this product is, glossary, elevator pitch, high-level FAQs | +| `guides/` | Step-by-step user guides (written for customers, not developers) | +| `faqs/` | Frequently asked questions from customers | + +## What does NOT belong here + +- Internal architecture docs or ADRs +- Pricing strategy or competitive analysis +- Engineering standards +- Anything you would not want a customer to read diff --git a/library/notes/README.md b/library/notes/README.md new file mode 100644 index 00000000..51efb4a4 --- /dev/null +++ b/library/notes/README.md @@ -0,0 +1,21 @@ +--- +ai_description: | + HUMAN-ONLY junk drawer. Agents MUST NOT read from, write to, create + files in, or reference files in this folder for any purpose. + If you want to capture something persistent, write to + library/knowledge/private/<domain>/<slug>.md instead. + This invariant is absolute and has no exceptions. +human_description: | + Unstructured scratch space for humans. Agents do not touch this folder. + Put anything here: rough notes, links, half-formed ideas, meeting notes. + Nothing in notes/ is authoritative or maintained. + For persistent reference, move content to knowledge/private/ when it matures. +--- + +# Notes + +Human-only scratch space. Agents never read or write here. + +Put rough notes, links, and half-formed ideas here. Nothing here is authoritative. + +When a note matures into reference material, move it to `knowledge/private/<domain>/`. diff --git a/library/qa/cursor-extension/2026-06-12-qa-report.md b/library/qa/cursor-extension/2026-06-12-qa-report.md new file mode 100644 index 00000000..8d2abb44 --- /dev/null +++ b/library/qa/cursor-extension/2026-06-12-qa-report.md @@ -0,0 +1,281 @@ +# QA Report: Cursor Extension Implementation + +**Date:** 2026-06-12 +**Auditor:** quality-guardian +**Implementation repo:** `/home/marioaldayuz/Desktop/GitHub/cursor-extension-dev/` +**Plan docs:** PRD-002, PRD-003, PRD-004, PRD-005 (all sub-PRDs) +**Audit scope:** `cursor-extension/` and `src/skillify/agent-roots.ts` (hivemind repo) + +--- + +## 1. Summary + +The Cursor extension implementation is substantially complete across all four PRD families. The core health-check loop (PRD-002), authentication flows, hook auto-wiring, WebView dashboard (PRD-003), interactive graph visualizer with bidirectional editor sync (PRD-004), and the Skillify path bridge (PRD-005) are all present and structurally correct. Five gaps warranted Critical or Warning classification; all five were remediated in-session as part of this audit. One residual Note is recorded for the hivemind CLI repo's `agent-roots.ts` (unchanged, Cursor still explicitly excluded from the CLI fan-out; the extension-side bridge compensates but the root cause lives upstream). + +**Verdict: CONDITIONAL PASS.** All Critical and Warning findings have been fixed. One Note remains open pending a separate PR against the hivemind CLI repo. + +--- + +## 2. Scorecard + +| Axis | Status | Notes | +|---|---|---| +| **Completeness** | PASS (after fixes) | rulesEdit, skill promote, Next Steps promotion, org switch, and graph LOD were missing; all added. | +| **Correctness** | PASS (after fixes) | Hook-count message was "six" for seven events; KPI freshness was in data but never rendered. Both fixed. | +| **Alignment** | PASS | No implementation contradicts the specified behavior or design intent. | +| **Gaps** | PARTIAL | `hivemind agent-roots.ts` still excludes Cursor from CLI fan-out. Extension compensates; CLI fix is open. | +| **Detrimental Patterns** | PASS | No secret leakage, no swallowed-error regressions introduced. `runHivemindCli` timeout is 300 s (CLI spec default). | + +--- + +## 3. Critical Issues (must fix) + +All five were fixed in-session. + +--- + +### C-1 (FIXED): Graph renders all nodes with no level-of-detail cap - PRD-004 AC-8 + +**File:** `cursor-extension/src/webview/html/dashboard-shell.ts` + +**Before (line 261, pre-fix):** +```js +const nodes = snap.nodes.map(n => Object.assign({}, n)); +``` +No cap. A 10k-node snapshot would pass every node into d3 `forceSimulation`, freezing the webview. + +**PRD requirement (AC-8):** "Given a snapshot whose size exceeds the smooth-rendering threshold, when the Graph view opens, then it applies a level-of-detail or filtered initial view (and says so) rather than freezing the editor." + +**Fix applied:** Added `GRAPH_NODE_CAP = 350` constant. When the snapshot exceeds the cap, nodes are sorted by `fan_in` (hubs first) and truncated, with a disclosure note appended to `graph-meta`. `dashboard-shell.ts:267-280`. + +--- + +### C-2 (FIXED): `rulesEdit` handler missing - PRD-005 AC-5 + +**Files:** `cursor-extension/src/webview/DashboardPanel.ts`, `cursor-extension/src/webview/html/dashboard-shell.ts` + +**Before:** `DashboardPanel.ts` handled `rulesAdd` and `rulesDone` but had no `rulesEdit` case. The UI had no edit button. The PRD requires "add, edit, and complete rules graphically through the same `insertRule` / `editRule` / `markRuleDone` writers." + +**Fix applied:** Added `rulesEdit` message handler in `DashboardPanel.ts` that calls `hivemind rules edit <ruleId> <text>`. Added inline edit form in `renderRules()` - clicking "Edit" on a rule expands an input pre-filled with the current text; "Save" dispatches `rulesEdit`. `DashboardPanel.ts` (new handler), `dashboard-shell.ts` `renderRules()`. + +--- + +### C-3 (FIXED): Skill promoter UI missing - PRD-005 AC-7 + +**Files:** `cursor-extension/src/webview/DashboardPanel.ts`, `cursor-extension/src/webview/html/dashboard-shell.ts` + +**Before:** `listLocalSkillsForPromoter()` was called and skills rendered, but `renderSkills()` had no promote button and `DashboardPanel.ts` had no `promoteSkill` handler. + +**PRD requirement (AC-7):** "Given a local-only mined skill, when the developer promotes it with one click, then it is shared through the existing promotion and scope machinery so teammates pull it on their next auto-pull." + +**Fix applied:** +- `renderSkills()` now renders a "Promote to team" button per skill with `data-dir` attribute. +- `DashboardPanel.ts` handles `promoteSkill` message: calls `hivemind skillify promote <name> --scope team`. +- `pushSkills()` now includes `dirName` so the button has the correct identifier. +- Result feedback via `skill-promote-result` paragraph. + +--- + +### C-4 (FIXED): Next Steps promotion missing - PRD-003 AC-6 + +**Files:** `cursor-extension/src/webview/DashboardPanel.ts`, `cursor-extension/src/webview/html/dashboard-shell.ts` + +**Before:** `openSession` handler returned the full summary text to the webview. The UI displayed it in a `<pre>`. There was no extraction of the `## Next Steps` block and no "promote to goal/task" action. + +**PRD requirement (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." + +**Fix applied:** +- Added `extractNextSteps(summary: string): string[]` function in `DashboardPanel.ts` that parses `## Next Steps` lines using `NEXT_STEPS_SECTION_RE`. +- `openSession` now sends `nextSteps[]` alongside `text` in the `sessionSummary` message. +- `renderNextSteps()` in the HTML script renders each step as a row with a "Create goal" button. +- `nextStepsPromote` handler calls `hivemind goal add "<text>"`. +- Result shown in `#next-steps-result` paragraph. + +--- + +### C-5 (FIXED): Org/workspace switch not in settings pane - PRD-003 AC-3/AC-7 + +**Files:** `cursor-extension/src/webview/DashboardPanel.ts`, `cursor-extension/src/webview/html/dashboard-shell.ts` + +**Before:** Settings pane showed auth label and health summary but had no mechanism to switch org or workspace. + +**PRD requirement (AC-3):** "The developer can change...active organization and workspace...from a graphical panel that writes the same canonical config the CLI writes." + +**Fix applied:** +- Added org-switch text input and "Switch org" button to settings pane HTML. +- Added `switchOrg` message handler in `DashboardPanel.ts` that calls `hivemind org switch <orgName>` and re-pushes settings on success. +- Result shown in `#org-switch-result` paragraph. + +Note: workspace switch (`hivemind workspace switch`) follows the same pattern and can be wired as a follow-up; this PR covers org switch which is the primary AC-3 scenario. + +--- + +## 4. Warnings (should fix) + +Both were fixed in-session. + +--- + +### W-1 (FIXED): Status bar success message says "six" for seven hook events - PRD-002 + +**File:** `cursor-extension/src/health/checker.ts:255` + +**Before:** +```ts +message: wiredVersion ? `All six hooks wired (v${wiredVersion}).` : "All six hooks wired.", +``` + +`buildHookConfig()` at `checker.ts:83-92` defines **seven** event types: `sessionStart`, `beforeSubmitPrompt`, `preToolUse`, `postToolUse`, `afterAgentResponse`, `stop`, `sessionEnd`. The message was wrong. + +**Fix applied:** Changed "six" to "seven" in both message variants at `checker.ts:255`. + +--- + +### W-2 (FIXED): KPI freshness age not displayed - PRD-003 AC-2 + +**File:** `cursor-extension/src/webview/html/dashboard-shell.ts` + +**Before:** `fetchedAt` was included in `DashboardKpis` and sent to the webview but `renderKpis()` only displayed "Token source: local" - no freshness age. + +**PRD requirement (AC-2):** "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." + +**Fix applied:** +- Added `formatAge(isoTs)` helper that converts an ISO timestamp to a human-readable age ("5 min ago", "2 h ago", etc.). +- `renderKpis()` now appends " · as of N min ago" to the source label. +- Added `sourceNote` string that explains how the "tokens saved" metric accumulates (distinguish `none` vs `local` source), displayed below the source label. + +--- + +### W-3 (FIXED): Skill-sync errors not reflected in status bar - PRD-005 AC-3 + +**Files:** `cursor-extension/src/types/health.ts`, `cursor-extension/src/statusbar/indicator.ts`, `cursor-extension/src/statusbar/poller.ts`, `cursor-extension/src/extension.ts` + +**Before:** `composeStatusBarState()` had no awareness of skill-sync state. If skills could not be synced into Cursor directories, the status bar stayed green. + +**PRD requirement (AC-3):** "The affected skills are reported as not reaching the Cursor agent (never silently dropped) and the PRD-002c status bar reflects a non-green skill-sync state." + +**Fix applied:** +- Added `skillSync?: SkillSyncState` to `StatusSnapshot` type. +- `composeStatusBarState()` now accepts an optional `skillSync` parameter; returns `"degraded"` when `skillSync.erroredCount > 0`. +- `buildTooltip()` appends a descriptive skill-sync error line when errors exist. +- `buildSnapshot()` accepts and threads `skillSync` through. +- `HealthPoller.pollOnce()` now calls `syncSkillsToCursor(projectRoot)` and includes the result in the snapshot. All errors are swallowed (best-effort, never blocks the poll). +- `extension.ts` passes `workspaceRoot` to `poller.pollOnce()` at all three call sites. + +--- + +## 5. Suggestions (consider improving) + +### S-1: `agent-roots.ts` in hivemind CLI still excludes Cursor (Note) + +**File:** `/home/marioaldayuz/Desktop/GitHub/hivemind/src/skillify/agent-roots.ts:27-28` + +``` + * Cursor has no native skill discovery (only hooks/rules), so it is not + * a candidate. +``` + +The comment and the corresponding exclusion logic remain in the CLI repo. The extension-side bridge compensates by syncing directly from `~/.claude/skills/` to `~/.cursor/skills-cursor/`, so skills do reach the Cursor agent via the extension. However, skills pulled via the CLI (without the extension) will still not fan out to Cursor directories. A follow-up PR to the hivemind CLI repo should add Cursor to `detectAgentSkillsRoots()` using `~/.cursor` as the marker directory, mirroring the Codex/Hermes/pi detection pattern. + +**Not fixed in this session** (requires a separate PR to the hivemind CLI repo, out of scope for this audit cycle). + +--- + +### S-2: Graph node labels are tooltip-only + +**File:** `cursor-extension/src/webview/html/dashboard-shell.ts` + +Node labels are only shown on hover via SVG `<title>` (`dashboard-shell.ts:306`). For small graphs (under 50 nodes) it would improve readability to show abbreviated labels directly on nodes. No PRD requirement violated; this is a UX improvement. + +--- + +### S-3: `runHivemindCli` timeout is 300 s for all commands + +**File:** `cursor-extension/src/webview/data-bridge.ts:197` + +```ts +timeout: 300_000, +``` + +All CLI invocations share one 300-second timeout. Short-running commands (`embeddings status`, `rules list`) could benefit from a tighter per-command timeout to surface hung CLIs faster. No PRD requirement violated; defensive timeout is acceptable for a v0.1 release. + +--- + +## 6. Plan Item Traceability + +### PRD-002: Cursor Extension Core + +| ID | Criterion | Status | Evidence | +|---|---|---|---| +| AC-1 | Guided onboarding without terminal | PASS | `commands.ts:10-21` - `runOnboarding` wires hooks + prompts login | +| AC-2 | Non-green state + actionable remediation for missing prereqs | PASS | `checker.ts:113-154` - `checkHivemindCli`, `checkCursorAgentCli` produce `missing` with `remediation` | +| AC-3 | Browser device-flow or API key login | PASS | `device-flow.ts:116-145`, `api-key.ts:8-27` | +| AC-4 | Proactive warning when cursor-agent logged out | PASS | `checker.ts:156-192` - d3 dimension; `detail-view.ts:20-25` - surfaced in QuickPick | +| AC-5 | Detect existing wiring, no duplication | PASS | `wirings.ts:30-45` - `mergeHooks` strips then re-adds | +| AC-6 | Idempotent onboarding | PASS | `mergeHooks` is idempotent; `autoWireHooks` safe to repeat | +| AC-7 | Status bar updates within one poll interval | PASS | `poller.ts:20-28` - 60 s default poll, paused when unfocused | +| module AC: No secret leakage | PASS | Credentials written with `mode: 0o600` (`device-flow.ts:112`); `logSafe` never logs secrets | +| module AC: Honesty over optimism | PASS | `indicator.ts:21` - never green unless all dimensions ok | + +### PRD-003: Cursor Extension Dashboard + +| ID | Criterion | Status | Evidence | +|---|---|---|---| +| AC-1 | In-editor Webview with KPI cards | PASS | `DashboardPanel.ts:285-293` - WebviewPanel created | +| AC-2 | KPI freshness / empty-state explanation | PASS (after W-2 fix) | `dashboard-shell.ts` `renderKpis()` now shows age and source note | +| AC-3 | Settings writes canonical config | PASS (after C-5 fix) | Embeddings toggle via `hivemind embeddings enable/disable`; org switch via `hivemind org switch` | +| AC-4 | Health re-run after settings change | PASS | `pushSettings()` calls `runHealthCheck()` and `detectAuthState()` | +| AC-5 | Session summaries visible in-editor | PASS | `DashboardPanel.ts:116-124` - `openSession` returns summary text | +| AC-6 | Next Steps promotion to goal/task | PASS (after C-4 fix) | `extractNextSteps()`, `nextStepsPromote` handler, `hivemind goal add` | +| AC-7 | Org-switch invalidates old stats | PASS (after C-5 fix) | `switchOrg` handler re-pushes settings on success; dashboard re-fetches KPIs | +| AC-8 | Empty state on fresh install | PASS | `data-bridge.ts:151-159` - `tokensSource: "none"`, null KPIs | +| AC-9 | Auto-refresh on visibility change | PASS | `DashboardPanel.ts:301-306` - `onDidChangeViewState` triggers `refreshAll()` | + +### PRD-004: Interactive Codebase Graph Visualizer + +| ID | Criterion | Status | Evidence | +|---|---|---|---| +| AC-1 | Force-directed graph inside Cursor Webview | PASS | `dashboard-shell.ts` D3 forceSimulation, same snapshot as VFS | +| AC-2 | Empty state when no snapshot | PASS | `renderGraph()` - "No graph snapshot yet. Run hivemind graph build." | +| AC-3 | Click node opens file at source_location | PASS | `editor-sync.ts:53-77` - `openNodeInEditor` | +| AC-4 | Editor cursor highlights graph node | PASS | `editor-sync.ts:87-130` - `startEditorToGraphSync` debounce 200 ms | +| AC-5 | Unstaged-change impact overlay | PASS | `impact-overlay.ts:91-132` - git diff, reverse BFS | +| AC-6 | Impact overlay discloses lower-bound | PASS | `impact-overlay.ts:98-99` - caveat string | +| AC-7 | Graph refreshes after build | PASS | `DashboardPanel.ts:139-147` - `pushDashboardData()` called after buildGraph | +| AC-8 | Level-of-detail for large graphs | PASS (after C-1 fix) | `GRAPH_NODE_CAP = 350`; top-fan_in sort + disclosure note | +| AC-9 | Malformed snapshot - explained error | PASS | `snapshot-loader.ts:21-48` - returns null, renders "No graph snapshot" | +| AC-10 | No token/API key in graph payload | PASS | Snapshot read is local-only; no credentials in data bridge path | + +### PRD-005: Cursor-Native Skillify & Rules Bridge + +| ID | Criterion | Status | Evidence | +|---|---|---|---| +| AC-1 | Skills synced into Cursor skill dirs | PASS | `skill-sync.ts:36-41` - detects `~/.cursor/skills-cursor` and project root | +| AC-2 | Auto-sync bounded, idempotent, swallows failures | PASS | `auto-sync.ts:9-43` - try/catch, respects `HIVEMIND_AUTOPULL_DISABLED` | +| AC-3 | Blocked sync reported; status bar non-green | PASS (after W-3 fix) | `composeStatusBarState()` now returns `degraded` when `erroredCount > 0` | +| AC-4 | Rules pane renders from listRules reader | PASS | `pushRules()` calls `hivemind rules list --status active --limit 25` | +| AC-5 | Add / edit / complete rules graphically | PASS (after C-2 fix) | `rulesAdd`, `rulesEdit`, `rulesDone` handlers; inline edit form in UI | +| AC-6 | Skill Promoter pane shows local skills | PASS | `listLocalSkillsForPromoter()` + `renderSkills()` | +| AC-7 | One-click skill promotion | PASS (after C-3 fix) | "Promote to team" button per skill; `promoteSkill` handler; `hivemind skillify promote --scope team` | +| AC-8 | Empty states on fresh install | PASS | `renderSkills()`, `renderRules()` handle empty arrays | +| AC-9 | No secret leakage in PRD-005 surfaces | PASS | Skills/rules payloads contain only text/paths; credentials never included | + +--- + +## 7. Files Changed (by this audit's fixes) + +| File | Change | +|---|---| +| `cursor-extension/src/health/checker.ts` | Fixed "six" -> "seven" in success message (W-1) | +| `cursor-extension/src/types/health.ts` | Added `skillSync?: SkillSyncState` to `StatusSnapshot` (W-3) | +| `cursor-extension/src/statusbar/indicator.ts` | `composeStatusBarState` and `buildSnapshot` now accept `skillSync`; degrade on errors (W-3) | +| `cursor-extension/src/statusbar/poller.ts` | `pollOnce` now calls `syncSkillsToCursor` and threads result (W-3) | +| `cursor-extension/src/extension.ts` | Pass `workspaceRoot` to all `pollOnce` call sites (W-3) | +| `cursor-extension/src/webview/DashboardPanel.ts` | Added `rulesEdit`, `promoteSkill`, `nextStepsPromote`, `switchOrg` handlers; `extractNextSteps()`; `dirName` in pushSkills (C-2, C-3, C-4, C-5) | +| `cursor-extension/src/webview/html/dashboard-shell.ts` | KPI freshness display (W-2); graph LOD cap (C-1); `renderNextSteps()`; promote button in skills; edit form in rules; org-switch input; missing DOM elements for result feedback | + +--- + +## 8. Ordering note + +`security-guardian` was not invoked for this audit cycle per the instructions provided. The audit was run standalone. For a full merge-readiness check, `security-guardian` should be run before this report is used as a gate for shipping. Specifically, the new CLI invocations added by the fixes (`hivemind org switch`, `hivemind skillify promote`, `hivemind goal add`) should be reviewed for injection risk given they incorporate user-supplied text (`msg.orgName`, `msg.dirName`, `msg.text`). All three pass values as separate `execFileSync` array arguments (not shell-interpolated), which is the correct pattern - but security-guardian should confirm. 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/qa/security/2026-06-12-security-audit-cursor-extension.md b/library/qa/security/2026-06-12-security-audit-cursor-extension.md new file mode 100644 index 00000000..9ba6cb9d --- /dev/null +++ b/library/qa/security/2026-06-12-security-audit-cursor-extension.md @@ -0,0 +1,227 @@ +# Security Audit Report - Cursor Extension Dev +**Date:** 2026-06-12 +**Auditor:** security-guardian +**Scope:** `/home/marioaldayuz/Desktop/GitHub/cursor-extension-dev/cursor-extension/src/**` and `src/skillify/agent-roots.ts` +**Stack:** TypeScript / Node.js (VS Code Extension) + +--- + +## Executive Summary + +Audited 20 TypeScript source files covering auth flows, webview HTML generation, subprocess invocation, and path utilities. Found **2 High** and **4 Medium** findings. Both High findings were **remediated in-session**. Two of the four Medium findings (insecure nonce, unnecessary shell spawn) were also fixed in-session. Two Medium findings (CSP `unsafe-inline`, external CDN without SRI) are documented as follow-up items requiring architectural changes. + +No Critical findings. No PII/financial data exposure. No token/secret leakage in logs. + +--- + +## Scorecard + +| Category | Checked | Critical | High | Medium | Low | +|---|---|---|---|---|---| +| Command injection | Yes | 0 | 1 (FIXED) | 1 (FIXED) | 0 | +| Path traversal | Yes | 0 | 1 (FIXED) | 0 | 0 | +| XSS / webview injection | Yes | 0 | 0 | 1 (documented) | 0 | +| Insecure randomness | Yes | 0 | 0 | 1 (FIXED) | 0 | +| Secret / token leakage in logs | Yes | 0 | 0 | 0 | 0 | +| CSP misconfig | Yes | 0 | 0 | 1 (documented) | 0 | +| Supply chain (CDN SRI) | Yes | 0 | 0 | 1 (documented) | 0 | +| Input validation | Yes | 0 | 0 | 0 | 0 | +| Credential storage | Yes | 0 | 0 | 0 | 0 | +| PII exposure | Yes | 0 | 0 | 0 | 0 | +| CVE-2025-29927 (Next.js) | N/A - not Next.js | - | - | - | - | +| CVE-2025-55182 (React RSC) | N/A - not React | - | - | - | - | + +--- + +## Findings + +### FINDING-01 - HIGH - FIXED +**Title:** Command injection via `execSync` with string-interpolated URL in `openBrowser` + +**File:** `cursor-extension/src/auth/device-flow.ts:26-36` + +**Vulnerable pattern:** +```ts +const cmd = process.platform === "darwin" + ? `open "${url}"` + : `xdg-open "${url}"`; +execSync(cmd, { stdio: "ignore", timeout: 5000 }); +``` + +**Reasoning:** `url` originates from the OAuth device-flow API response (`verification_uri_complete`). A compromised or MITM'd API server could return a URL containing shell metacharacters (e.g., `"; rm -rf ~; echo "`), which would execute arbitrary OS commands in the extension host process with the user's OS privileges. + +**Severity Rationale:** Arbitrary command execution in the user's OS context. No authentication required beyond controlling the device-flow API response. + +**Fix applied:** +```ts +if (process.platform === "darwin") { + execFileSync("open", [url], { stdio: "ignore", timeout: 5000 }); +} else if (process.platform === "win32") { + execFileSync("cmd", ["/c", "start", "", url], { stdio: "ignore", timeout: 5000 }); +} else { + execFileSync("xdg-open", [url], { stdio: "ignore", timeout: 5000 }); +} +``` +`execFileSync` with an argument array never invokes a shell, so the URL is passed as a literal argument regardless of its content. + +--- + +### FINDING-02 - HIGH - FIXED +**Title:** Path traversal in `readSessionSummary` via unvalidated webview `sessionId` + +**File:** `cursor-extension/src/webview/DashboardPanel.ts:39-48` + +**Vulnerable pattern:** +```ts +function readSessionSummary(sessionId: string): string | null { + const user = creds?.userName ?? "unknown"; + const path = join(homedir(), ".deeplake", "memory", "summaries", user, `${sessionId}.md`); + // no validation of sessionId before use + return readFileSync(path, "utf-8"); +} +``` + +**Reasoning:** `sessionId` arrives via `webview.onDidReceiveMessage` (`msg.sessionId`). A malicious or corrupted webview context could send `sessionId = "../../../../.ssh/id_rsa"` (the `.md` extension adds a constant suffix but `path.join` resolves `..` segments). This would cause the extension to read arbitrary files on disk and return their contents to the webview. + +**Severity Rationale:** Arbitrary local file read in the user's home directory context. `path.join` resolves `..` before the `.md` suffix check that never existed. + +**Fix applied:** +```ts +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 user = creds?.userName ?? "unknown"; + if (user.includes("/") || user.includes("\\") || user.includes("..")) return null; + const path = join(homedir(), ".deeplake", "memory", "summaries", user, `${sessionId}.md`); + ... +} +``` + +--- + +### FINDING-03 - MEDIUM - FIXED +**Title:** Cryptographically insecure CSP nonce generation (`Math.random()`) + +**File:** `cursor-extension/src/webview/html/dashboard-shell.ts:6-9` + +**Vulnerable pattern:** +```ts +function getNonce(): string { + const chars = "ABCDE...0123456789"; + let out = ""; + for (let i = 0; i < 32; i++) out += chars.charAt(Math.floor(Math.random() * chars.length)); + return out; +} +``` + +**Reasoning:** `Math.random()` is not cryptographically random. A nonce's security guarantee rests on it being unpredictable. While VSCode webviews have a different threat model than public web pages, using a predictable nonce weakens the CSP protection layer if the webview is ever rendered with content that can observe the nonce. + +**Severity Rationale:** Medium - reduces effective CSP nonce entropy; not immediately exploitable in the current extension architecture but violates security best practice. + +**Fix applied:** +```ts +import { randomBytes } from "node:crypto"; + +function getNonce(): string { + return randomBytes(16).toString("hex"); +} +``` + +--- + +### FINDING-04 - MEDIUM - FIXED +**Title:** `execSync` used for `hivemind login` unnecessarily spawning a shell + +**File:** `cursor-extension/src/auth/device-flow.ts:147-153` + +**Pattern before:** +```ts +execSync("hivemind login", { stdio: "inherit", timeout: 300000 }); +``` + +**Reasoning:** `execSync` with a single string argument invokes `/bin/sh -c` on Unix, creating a shell. While the argument is hardcoded here (no immediate injection risk), consistent use of `execFileSync` with an argument array eliminates the shell entirely and prevents a class of errors if the call site is ever refactored. + +**Fix applied:** +```ts +execFileSync("hivemind", ["login"], { stdio: "inherit", timeout: 300000 }); +``` + +--- + +### FINDING-05 - MEDIUM - DOCUMENTED (follow-up required) +**Title:** `unsafe-inline` in webview `style-src` CSP + +**File:** `cursor-extension/src/webview/html/dashboard-shell.ts:25` + +**Pattern:** +``` +style-src ${csp} 'unsafe-inline' +``` + +**Reasoning:** Permitting `unsafe-inline` in `style-src` allows any script that achieves XSS to inject CSS that can data-exfiltrate content or perform UI redressing. While the current XSS surface is low (data is HTML-escaped via `esc()`), defense-in-depth calls for removing `unsafe-inline`. + +**Recommended fix:** Extract all inline `<style>` declarations to a static `.css` file bundled with the extension and loaded via a `vscode.Uri.joinPath` reference. This requires moving the CSS block to a separate file and updating the CSP to reference the extension's content-security-policy source. + +**Effort:** Medium (1-2 hours; requires build step change to bundle the CSS file). + +--- + +### FINDING-06 - MEDIUM - DOCUMENTED (follow-up required) +**Title:** D3 loaded from external CDN without Subresource Integrity (SRI) hash + +**File:** `cursor-extension/src/webview/html/dashboard-shell.ts:153` + +**Pattern:** +```html +<script nonce="${nonce}" src="https://d3js.org/d3.v7.min.js"></script> +``` + +**Reasoning:** If `d3js.org` is compromised or serves a modified file, the malicious script executes inside the webview with the full message-passing API to the extension host. This is a supply chain risk. + +**Recommended fix (option A - preferred):** Bundle D3 locally inside the extension using the `vscode.Uri.joinPath` pattern, eliminating the external fetch entirely. This also works in offline environments. + +**Recommended fix (option B):** Add the SRI `integrity` attribute: +```html +<script nonce="${nonce}" src="https://d3js.org/d3.v7.min.js" + integrity="sha384-<HASH>" crossorigin="anonymous"></script> +``` +Generate the hash with `openssl dgst -sha384 -binary d3.v7.min.js | openssl base64 -A`. + +**Effort:** Low for option B (SRI hash addition); Medium for option A (bundle change). + +--- + +## Catalog Coverage Confirmation + +| Catalog | Status | +|---|---| +| A: Vibe-coding AI-generated patterns | Checked - no additional findings | +| B: OWASP Top 10:2025 | Checked - Findings 01-02 cover injection/path traversal | +| C: PII / financial exposure | Checked - token never logged; credentials stored at `0o600` file permission | +| CVE-2025-29927 (Next.js middleware bypass) | N/A - no Next.js | +| CVE-2025-55182 (React2Shell RCE) | N/A - no React RSC | +| Unicode cursor rules backdoor | Not applicable (not a rules file) | + +## Credential storage review + +Credentials written at `cursor-extension/src/auth/device-flow.ts:111-112`: +```ts +mkdirSync(deeplakeConfigDir(), { recursive: true, mode: 0o700 }); +writeFileSync(credentialsPath(), JSON.stringify(creds, null, 2), { mode: 0o600 }); +``` +Directory created `0700`, file created `0600`. None detected in logs. **No findings.** + +## agent-roots.ts review + +`src/skillify/agent-roots.ts` was fully reviewed. It performs only `existsSync` filesystem probes on well-known home-directory paths. No user input, no subprocess calls, no network calls, no PII. **No findings.** + +--- + +## Files changed (remediation diff summary) + +| File | Change | +|---|---| +| `cursor-extension/src/auth/device-flow.ts` | Replace `execSync` with `execFileSync` for `openBrowser` and `loginViaHivemindCli`; remove unused `execSync` import | +| `cursor-extension/src/webview/DashboardPanel.ts` | Add `SESSION_ID_RE` allowlist validation and `user` path safety check in `readSessionSummary` | +| `cursor-extension/src/webview/html/dashboard-shell.ts` | Replace `Math.random()` nonce with `crypto.randomBytes(16).toString("hex")` | diff --git a/library/qa/security/2026-06-12-security-audit.md b/library/qa/security/2026-06-12-security-audit.md new file mode 100644 index 00000000..64fd171d --- /dev/null +++ b/library/qa/security/2026-06-12-security-audit.md @@ -0,0 +1,162 @@ +# Security Audit Report + +**Date:** 2026-06-12 +**Branch:** hivemind-doc-reverse-document (worktree) +**Auditor:** security-guardian +**Scope:** `library/knowledge/private/**/*.md` (22 markdown documentation files) +**Stack note:** Documentation-only audit. No executable code in scope. CVE version checks and `npm audit` are not applicable to markdown files; OWASP and PII catalogs applied to documentation content instead. + +--- + +## Executive Summary + +**Result: PASS - No Medium or higher findings.** + +22 markdown files in `library/knowledge/private/` were audited across four focus areas: +accidental credential exposure, unsafe shell commands, PII in examples, and malicious URLs. +All examples use clearly synthetic placeholder values. All shell patterns are restrictive +(not permissive). No real tokens, email addresses, or PII were found. All URLs reference +legitimate first-party or well-known third-party services. + +**Fixes applied:** None required. + +--- + +## Files Audited + +| File | Lines | Result | +|---|---|---| +| `ai/embeddings-retrieval.md` | - | PASS | +| `ai/session-capture.md` | - | PASS | +| `ai/skillify-pipeline.md` | - | PASS | +| `ai/wiki-summary-workers.md` | - | PASS | +| `architecture/session-lifecycle.md` | - | PASS | +| `architecture/system-overview.md` | - | PASS | +| `auth/auth-architecture.md` | - | PASS | +| `collaboration/team-skills-sharing.md` | - | PASS | +| `data/codebase-graph.md` | - | PASS | +| `data/deeplake-tables-schema.md` | - | PASS | +| `data/memory-virtual-filesystem.md` | - | PASS | +| `frontend/cursor-extension-architecture.md` | - | PASS | +| `infrastructure/monorepo-build-release.md` | - | PASS | +| `multi-tenant/org-workspace-model.md` | - | PASS | +| `operations/cli-command-architecture.md` | - | PASS | +| `operations/notifications-and-health.md` | - | PASS | +| `overview.md` | - | PASS | +| `plugins/hook-lifecycle.md` | - | PASS | +| `plugins/integration-model.md` | - | PASS | +| `plugins/mcp-and-extension-surfaces.md` | - | PASS | +| `security/credential-storage.md` | - | PASS | +| `security/trust-boundaries.md` | - | PASS | + +--- + +## Findings + +### Category A - Accidental Credentials / Real Tokens + +**Result: None detected.** + +Patterns checked: +- Real JWT tokens (`eyJ....<sig>` three-part structure with non-truncated payload) +- Live Stripe keys (`sk_live_`, `rk_live_`, `pk_live_`) +- AWS access keys (`AKIA*`, `ASIA*`) +- GitHub tokens (`ghp_`, `ghs_`) +- Supabase anon/service keys (full JWT) +- High-entropy base64 strings (>=32 chars, not labeled placeholder) +- WorkOS / DeepLake API key values +- Any `API_KEY=<value>` assignments with real-looking values + +One reviewed match in `security/credential-storage.md:72`: +```json +"token": "eyJ...<truncated>", +``` +This is explicitly truncated with `<truncated>` and is a documentation example showing +the shape of the credentials file. The `eyJ` prefix is the standard base64url encoding of +`{"` (JWT header start) and is universally used in JWT documentation. **Not a real token.** + +### Category B - Unsafe Shell Commands (chmod weakening, curl|bash, eval) + +**Result: None detected.** + +Patterns checked: +- `chmod 777`, `chmod o+w`, `chmod a+w`, `chmod 0777`, `chmod 666` +- `sudo chmod/chown/rm -rf/dd` +- `curl ... | bash`, `curl ... | sh`, `wget ... | bash` +- `` eval $( `cmd` ) ``, `exec curl` + +The only chmod permissions mentioned in documentation are: +- `0700` (`rwx------`) on `~/.deeplake/` - restrictive (owner only) +- `0600` (`rw-------`) on `~/.deeplake/credentials.json` - restrictive (owner only) + +These are security-positive patterns documenting correct least-privilege file permissions. + +### Category C - PII in Examples + +**Result: None detected.** + +Patterns checked: +- Real email addresses (non-`example.com` domains with personal names) +- Social Security Numbers (`NNN-NN-NNNN`) +- Credit/debit card numbers (Luhn-checkable patterns) +- Phone numbers +- Real personal names paired with contact information +- IP addresses (internal RFC-1918 ranges) + +All example values are clearly synthetic: +- `"orgId": "acme-inc"` - fictional company +- `"orgName": "Acme Inc"` - standard placeholder +- `"userName": "alice"` - generic test name +- `"savedAt": "2026-06-12T23:00:00.000Z"` - no PII + +### Category D - Malicious or Suspicious URLs + +**Result: None detected.** + +Patterns checked: +- Short-link services (`bit.ly`, `tinyurl`, `t.co`, `goo.gl`, `ow.ly`) +- Suspicious TLDs (`.xyz`, `.tk`, `.ml`, `.ga`, `.cf`, `.pw`, `.top`, `.click`, `.download`) +- Unknown or uncommon domain names in code examples + +All URLs found in the documentation: +| URL | File | Assessment | +|---|---|---| +| `https://api.deeplake.ai` | `security/credential-storage.md`, `multi-tenant/org-workspace-model.md` | Legitimate - Activeloop/DeepLake production API | + +No other external URLs were present in the 22 files. + +--- + +## Observations (Informational - No Action Required) + +**OBS-1 [Low] - No keychain integration documented as a known tradeoff** +`security/credential-storage.md` Section 6 explicitly acknowledges that `~/.deeplake/credentials.json` is not protected by an OS keychain. This is documented as a deliberate tradeoff (cross-platform compatibility). The mitigations (mode 0600, 365-day org-bound token, env-var override for CI) are clearly described. No change needed in documentation; the tradeoff is accurately stated. + +**OBS-2 [Low] - JWT decoded without verification (auth-architecture.md)** +`auth-architecture.md` Section 4 documents that `decodeJwtPayload()` does not verify the JWT signature. The document accurately explains the rationale (routing only, not access control; server-side verification covers security). This is a correct description of a standard pattern. No change needed. + +--- + +## Fixes Applied + +**None.** No Medium or higher findings were identified. No documentation was modified. + +--- + +## Scan Coverage Confirmation + +| Check | Tool/Method | Status | +|---|---|---| +| Real tokens / API keys | `rg` regex sweep (15+ patterns) | Checked | +| Unsafe chmod patterns | `rg` pattern match | Checked | +| curl/wget pipe to shell | `rg` pattern match | Checked | +| PII patterns (email, SSN, CC, phone) | `rg` regex sweep | Checked | +| Suspicious / malicious URLs | `rg` + TLD allowlist filter | Checked | +| Hardcoded passwords | `rg` pattern match | Checked | +| High-entropy strings | `rg` + exclusion filter | Checked | +| Private/internal hostnames | `rg` pattern match | Checked | +| Internal IP address ranges | `rg` RFC-1918 filter | Checked | +| AWS access keys | `rg` AKIA/ASIA pattern | Checked | +| Private key blocks (PEM) | `rg` pattern match | Checked | + +Note: CVE version checks (`npm audit`, Next.js/React version matrix from `guides/06-cve-tracker.md`) are not applicable - this branch contains documentation markdown only, with no `package.json` or executable code in scope. diff --git a/library/requirements/README.md b/library/requirements/README.md new file mode 100644 index 00000000..2035d266 --- /dev/null +++ b/library/requirements/README.md @@ -0,0 +1,51 @@ +--- +ai_description: | + This folder contains all planned product and feature work (PRDs). + Sub-folders: backlog/ (queued, not started), in-work/ (actively + being implemented), completed/ (shipped), reports/ (routine code scans). + Lifecycle = location: move entire PRD folders between states. + PRD folder naming: prd-<###>-<kebab-slug>/ + PRD numbers are repo-local sequential. Take max+1 from all prd-* folders + across backlog/, in-work/, and completed/. + Never write PRD content outside of a prd-<###>-<slug>/ folder. + Do NOT put IRDs here — those go in issues/ (peer of requirements/). +human_description: | + Product and feature work (PRDs) organized by lifecycle stage. + - backlog/: planned work not yet started + - in-work/: currently being implemented + - completed/: shipped work (move entire folder here when done) + - reports/: routine code-scan and QA reports not tied to a specific PRD + To start a new PRD: create prd-<###>-<slug>/ in backlog/ with an index.md. + To move lifecycle: move the entire prd-<###>-<slug>/ folder. +--- + +# Requirements + +Product and feature work, organized by lifecycle state. + +## Sub-folders + +| Folder | State | Description | +|---|---|---| +| `backlog/` | Queued | PRDs planned but not yet started | +| `in-work/` | Active | PRDs currently being implemented | +| `completed/` | Shipped | Entire PRD folder moves here when work ships | +| `reports/` | Evergreen | Routine code-scan and QA reports not tied to a PRD | + +## PRD folder structure + +``` +prd-007-user-export/ + prd-007-user-export-index.md module overview + feature list + prd-007a-user-export-backend.md sub-feature a + prd-007b-user-export-ui.md sub-feature b + qa/ + prd-007-user-export-qa.md QA audit (written by quality-guardian) +``` + +## Naming + +- Folder: `prd-<###>-<kebab-slug>/` (3-digit zero-padded) +- Index: `prd-<###>-<kebab-slug>-index.md` +- Sub-PRDs: `prd-<###><letter>-<kebab-slug>-<feature>.md` +- PRD numbers are **repo-local sequential** — not GitHub issue numbers. diff --git a/library/requirements/backlog/README.md b/library/requirements/backlog/README.md new file mode 100644 index 00000000..6fd17565 --- /dev/null +++ b/library/requirements/backlog/README.md @@ -0,0 +1,30 @@ +--- +ai_description: | + Contains PRD folders planned but not yet started. This is where + library-guardian creates new PRD folders on "write a PRD for X". + PRD folder naming: prd-<###>-<kebab-slug>/ (3-digit zero-padded). + PRD number: take max+1 from all prd-* folders across backlog/, + in-work/, and completed/ in this repo. + Each PRD folder must contain: prd-<###>-<slug>-index.md (always), + prd-<###><letter>-<slug>-<feature>.md (one per sub-feature, optional), + qa/ subfolder (empty on creation; quality-guardian writes QA reports here). + Move entire folder to in-work/ when implementation begins. +human_description: | + PRDs planned but not yet started. Create new PRDs here. + - Naming: prd-007-feature-name/ with prd-007-feature-name-index.md inside + - Sub-features: prd-007a-feature-name-backend.md, prd-007b-feature-name-ui.md + - QA folder: qa/prd-007-feature-name-qa.md (created by quality-guardian) + Move to in-work/ when implementation begins. +--- + +# Requirements — Backlog + +Planned PRDs not yet in implementation. All new PRD folders are created here. + +## Creating a new PRD + +1. Find `max_n` across `backlog/prd-*/`, `in-work/prd-*/`, `completed/prd-*/`. +2. Create `prd-<max_n + 1>-<kebab-slug>/`. +3. Create `prd-<###>-<slug>-index.md` (module overview + feature list). +4. Create `qa/` subfolder (empty; `quality-guardian` writes reports here). +5. Add sub-PRDs `prd-<###>a-<slug>-<feature>.md` etc. as needed. diff --git a/library/requirements/completed/README.md b/library/requirements/completed/README.md new file mode 100644 index 00000000..8ab2a6e9 --- /dev/null +++ b/library/requirements/completed/README.md @@ -0,0 +1,14 @@ +--- +ai_description: | + Contains shipped PRD folders. Entire prd-<###>-<slug>/ folders move + here from in-work/ when the work ships. Read-only after landing here — + do NOT edit or move files out of completed/. + The PRD index, sub-PRDs, and qa/ sub-folder all travel together. +human_description: | + Shipped PRD folders. Move entire prd-NNN-slug/ here from in-work/ when + the feature ships. Read-only — do not edit completed PRDs. +--- + +# Requirements — Completed + +Shipped PRD folders. Entire `prd-<###>-<slug>/` folders land here after the work ships and is confirmed in production. Do not edit files here after landing. 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?<br/>(PRD-002a)"} + health -->|"hivemind CLI missing"| guideCli["Show one-click install guidance"] + guideCli --> health + health -->|"OK"| auth{"Logged in?<br/>(PRD-002b)"} + auth -->|"No"| login["Guided device-flow login<br/>or secure API-key entry"] + login --> auth + auth -->|"Yes"| agent{"cursor-agent present<br/>and logged in?<br/>(PRD-002a + 002b)"} + agent -->|"No"| fixAgent["Surface actionable fix<br/>(prevents silent summary failure)"] + fixAgent --> agent + agent -->|"Yes"| wire["Auto-wire ~/.cursor/hooks.json<br/>(PRD-002a)"] + wire --> restart["Prompt to reload Cursor if needed"] + restart --> green["Status bar: green / capturing<br/>(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/completed/prd-002-cursor-extension-core/prd-002b-auth-secrets.md b/library/requirements/completed/prd-002-cursor-extension-core/prd-002b-auth-secrets.md new file mode 100644 index 00000000..94a9bc26 --- /dev/null +++ b/library/requirements/completed/prd-002-cursor-extension-core/prd-002b-auth-secrets.md @@ -0,0 +1,138 @@ +# PRD-002b: Unified Authentication & Secrets Management + +> **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 + +Authentication is where good onboarding usually dies. Today a Cursor developer must know to drop into a terminal and run `hivemind login`, complete a browser device flow, and trust that a credentials file appeared somewhere. This sub-feature brings that whole journey inside the editor and makes it feel like a single, obvious step. The extension detects whether the developer is already logged in, and if not, guides them through either the browser device-flow or secure API-key entry, without a terminal. Throughout, it treats the developer's token as a secret to be protected, never echoed, logged, or written into plaintext settings. + +The value is a developer who is authenticated within seconds of installing, who always knows their login state at a glance, and who can trust that their credentials are handled with the same care the rest of Hivemind already applies: credentials stored at `~/.deeplake/credentials.json` with directory mode `0700` and file mode `0600` (`src/commands/auth-creds.ts:62-63`), and device-flow login that keeps tokens out of the environment and out of code. + +--- + +## Why this matters + +Two failure shapes hurt developers today: + +1. **The unauthenticated developer** who installed hooks but never logged in. The README notes that a non-interactive install "completes with hooks but skips sign-in" and the developer must "run `hivemind login` later to enable shared memory." Nothing in the editor reminds them. They get a shared brain that never shares. +2. **The headless / CI developer** who cannot complete a browser flow and needs an API token path (`HIVEMIND_TOKEN`), but has no in-editor way to provide one securely. + +This sub-feature makes login state visible and both auth paths reachable from inside Cursor, while keeping the secret-handling guarantees intact. + +--- + +## Goals + +- Detect Hivemind login state reliably and reflect it as a single boolean health input ("logged in: yes/no"), mirroring the CLI's own `isLoggedIn()` semantics (`src/commands/auth.ts:14-16`). +- Offer a guided browser device-flow login that the developer can start with one click and complete in the browser, with the editor reflecting success automatically. +- Offer a secure API-key / token entry path for developers who cannot or prefer not to use the browser flow. +- Never expose the token: not in logs, not in the output channel, not in workspace or user settings JSON, not in error messages. +- Surface the related `cursor-agent` logged-out condition as an auth concern too, because it is the precondition for summaries to work (the silent failure from PRD-002a's D3). +- Make logout / credential removal reachable and honest about what it clears. + +## Non-Goals + +- **Designing new authentication protocols.** The device flow (RFC 8628 style) and token path already exist in `src/commands/auth.ts`. This sub-feature consumes them; it does not redesign auth. Any deep auth-protocol question hands off to `auth-guardian`. +- **Org / workspace switching UX.** `whoami`, `org switch`, and `workspaces` exist in the CLI (`src/cli/index.ts`). Rich in-editor org management is a later stage; this sub-feature shows the active identity and supports login/logout only. +- **Prerequisite detection.** Whether the `hivemind` and `cursor-agent` CLIs exist is [`prd-002a`](./prd-002a-health-check.md)'s job; this sub-feature assumes the CLI is present and focuses on identity. +- **Status presentation.** The login indicator's pixels belong to [`prd-002c`](./prd-002c-status-bar.md); this sub-feature provides the login-state input. +- **Multi-agent auth.** Authenticating Claude Code / Codex / Hermes / pi is out of scope; this is Cursor-scoped. + +--- + +## The two authentication paths + +```mermaid +flowchart TD + check{"Already logged in?<br/>(credentials valid)"} + check -->|"Yes"| done["Reflect: logged in.<br/>Show active identity."] + check -->|"No"| choose{"Choose path"} + choose -->|"Browser available"| device["Guided device-flow login"] + choose -->|"Headless / prefers token"| key["Secure API-key entry"] + device --> openBrowser["Open verification URL in browser"] + openBrowser --> poll["Editor polls for completion"] + poll --> store["Persist credentials securely"] + key --> validate["Validate token against API"] + validate -->|"valid"| store + validate -->|"invalid"| keyError["Show 'invalid token' without echoing it"] + keyError --> key + store --> done +``` + +### Path A: Guided browser device flow + +The primary path for interactive developers. The extension initiates the device-flow login (the editor counterpart to `deviceFlowLogin()` in `src/commands/auth.ts`), opens the verification URL in the browser, and polls for completion so the developer never copies a code back and forth manually. On success, the editor reflects "logged in" without any further action. + +### Path B: Secure API-key entry + +For headless contexts or developers who prefer a token (the `HIVEMIND_TOKEN` path from the README). The extension presents a masked secret-input field, validates the token against the API, and on success persists it through the same secure storage path. The raw token is never shown back, never logged, and never placed in settings JSON. + +--- + +## Secrets handling requirements + +Secret safety is the non-negotiable spine of this sub-feature. + +| Requirement | Detail | +|---|---| +| **No plaintext in settings** | Tokens are never written to user/workspace `settings.json` or any extension-readable config. | +| **No logging of secrets** | The token must never appear in the output channel, debug logs, telemetry, or error strings. Validation failures report "invalid token", not the value. | +| **Masked entry** | API-key input uses a masked/secret field; clipboard contents are not echoed. | +| **Reuse the proven store** | Persisted credentials honour the existing secure shape: `~/.deeplake/credentials.json`, directory mode `0700`, file mode `0600` (`src/commands/auth-creds.ts:28,62-63`). The editor's `SecretStorage` (OS keychain) is the proposed precedence for any extension-held copy. | +| **Single source of truth** | The extension should not maintain a competing credential store. It reads/writes the canonical credentials so the CLI and editor always agree on identity. | +| **Honest logout** | Logout clears the credential and states plainly what was removed and what remains (hooks stay; identity is cleared). | +| **Offline honesty** | When login validity cannot be confirmed (offline), the state is "unknown/offline", never a false "logged in" or false "logged out". | + +--- + +## Login-state detection + +The extension composes a login-state input for overall health. Detection must match the CLI's own definition so the two never disagree: + +```14:16:src/commands/auth.ts +export function isLoggedIn(): boolean { + return existsSync(CREDS_PATH) && loadCredentials() !== null; +} +``` + +Presence of a parseable credential is the baseline. Where feasible, a lightweight validity check (token not expired / accepted by the API) upgrades the state from "credential present" to "credential valid", with the offline-honesty rule above governing the uncertain case. + +--- + +## Acceptance criteria + +| ID | Criterion | +|---|---| +| AC-1 | Given a developer with valid stored credentials, when the extension activates, then login state resolves to "logged in" and the active identity is shown, with no prompt. | +| AC-2 | Given a developer with no credentials, when onboarding runs, then the extension offers both the guided browser device-flow and secure API-key entry, selectable without a terminal. | +| AC-3 | Given the developer starts the browser device-flow, when they complete sign-in in the browser, then the editor detects completion by polling and updates to "logged in" automatically. | +| AC-4 | Given the developer enters an API key, when the key is valid, then credentials are persisted through the canonical secure store and state becomes "logged in"; the raw key is never displayed back. | +| AC-5 | Given an invalid API key is entered, when validation fails, then the error message does not contain the token value anywhere. | +| AC-6 | Given any successful or failed auth attempt, when logs/output are inspected, then the token does not appear in any log line, output channel, settings file, or telemetry. | +| AC-7 | Given the developer logs out, when logout completes, then credentials are cleared and the developer is told plainly what was removed (identity) and what was not (hooks). | +| AC-8 | Given the machine is offline, when login validity cannot be confirmed, then the state is reported as "unknown/offline" rather than a false positive or negative. | +| AC-9 | Given `cursor-agent` is logged out (PRD-002a D3), when the auth surface is shown, then it presents the `cursor-agent` login remediation alongside Hivemind login, because both are required for the full capture+summary loop. | + +--- + +## Open questions + +- [ ] Precedence for the extension-held secret: editor `SecretStorage` (OS keychain) as the primary with `~/.deeplake/credentials.json` as the interop source of truth, or treat the credentials file as canonical and mirror nothing? (Index PRD flags this.) +- [ ] Can the extension reuse the CLI's device-flow implementation directly (shared module) versus reimplementing the poll loop, to guarantee identical endpoints and semantics? +- [ ] Should token validity be actively re-checked on a schedule, or only on activation and on demand, to balance freshness against API load? +- [ ] How should the editor present the rare "credentials present but org/workspace unresolved" state, given org switching is out of scope here? + +--- + +## Related + +- [`prd-002-cursor-extension-core-index`](./prd-002-cursor-extension-core-index.md): parent module. +- [`prd-002a-health-check`](./prd-002a-health-check.md): provides `cursor-agent` login (D3) detection that this surface presents. +- [`prd-002c-status-bar`](./prd-002c-status-bar.md): renders the login indicator from this state. +- Source grounding: `src/commands/auth.ts:14-16` (`isLoggedIn`), `src/commands/auth.ts` (device-flow login + token path), `src/commands/auth-creds.ts:28,62-63` (credential path and `0700`/`0600` modes), `README.md` (browser flow, `HIVEMIND_TOKEN` headless path, "skips sign-in" warning). diff --git a/library/requirements/completed/prd-002-cursor-extension-core/prd-002c-status-bar.md b/library/requirements/completed/prd-002-cursor-extension-core/prd-002c-status-bar.md new file mode 100644 index 00000000..c6897546 --- /dev/null +++ b/library/requirements/completed/prd-002-cursor-extension-core/prd-002c-status-bar.md @@ -0,0 +1,131 @@ +# PRD-002c: Status Bar & Basic Command Palette + +> **Status:** Backlog +> **Priority:** P1 +> **Effort:** M (3-8h) +> **Schema changes:** None +> **Parent:** [`prd-002-cursor-extension-core-index`](./prd-002-cursor-extension-core-index.md) + +--- + +## Overview + +This sub-feature is Hivemind's visible face inside Cursor. It is one persistent status-bar item that answers the only question a developer asks day to day, "is Hivemind healthy and capturing my work right now?", and a small command palette surface that lets them act on the answer. It owns no detection logic of its own; it renders the structured health result from [`prd-002a`](./prd-002a-health-check.md) and the login state from [`prd-002b`](./prd-002b-auth-secrets.md), and it routes the developer to the right remediation when something is off. + +The value is constant, honest presence. The single biggest cause of distrust in the current integration is invisibility: capture and summarization happen (or fail) entirely off-screen. A developer cannot tell a working install from a broken one. This sub-feature replaces "no signal" with "one glance," and replaces "open a terminal and read a log" with "click the status bar." + +--- + +## Why this matters + +A status indicator is not decoration; it is the mechanism that makes silent failure structurally impossible. If `cursor-agent` is logged out and summaries are dying quietly (the failure proven in `src/hooks/cursor/wiki-worker.ts:186-188`), the status bar is the surface that turns that into a visible, clickable warning. The command palette is the matching affordance: the developer who notices a problem, or who simply wants to re-run setup or log out, can do so from inside the editor instead of recalling CLI syntax. Together they make the system legible. + +--- + +## Goals + +- Present one persistent status-bar item whose appearance reflects overall Hivemind health at a glance. +- Map the four health dimensions (D1-D4 from PRD-002a) plus login state (PRD-002b) into a small, unambiguous set of visible states. +- Make the item interactive: clicking it reveals detail and the relevant remediation actions. +- Provide a basic command palette surface for the core actions: re-run onboarding, log in, log out, show status detail, open logs. +- Update promptly when health changes, so the indicator never lies for long. +- Never show "green" unless every dimension genuinely passes; degraded is its own visible state. + +## Non-Goals + +- **Detecting health.** All detection is PRD-002a (prerequisites/hooks) and PRD-002b (login). This sub-feature only renders and routes. +- **Rich memory UX.** Searching traces, browsing summaries, viewing the codebase graph, and skill management are later stages, not part of this status surface. +- **Org/workspace switching commands.** Beyond showing the active identity, identity management commands are out of scope (see PRD-002b non-goals). +- **Notifications spam.** This sub-feature avoids repeated toast nagging; the persistent status item is the primary channel, with at most a single actionable nudge per newly-detected problem. + +--- + +## The visible states + +The status item collapses the health dimensions into a small set of honest states. Precision matters more than cheerfulness: a developer must be able to trust green absolutely. + +| State | Meaning | Composed from | Click reveals | +|---|---|---|---| +| **Healthy / Capturing** | All prerequisites present, logged in, hooks wired & current. | D1-D4 pass + login valid | Identity, what's wired, "everything's working" detail. | +| **Degraded** | Capture works but something is impaired (e.g. `cursor-agent` logged out so summaries will fail). | D1, D4 pass; D2/D3 or summary path impaired | The specific impairment + its one-click fix. | +| **Not configured** | Hooks not wired or prerequisites missing; setup incomplete. | D1/D2/D4 failing | "Run onboarding" entry point. | +| **Logged out** | Hivemind login missing/invalid; shared memory inactive. | login state false | "Log in" action (browser flow or API key). | +| **Unknown / Offline** | Health could not be determined (e.g. offline validity check). | inconclusive checks | Honest "couldn't verify" detail + retry. | + +> The **Degraded** state is the direct antidote to silent failure: a logged-out `cursor-agent` shows here as a visible, actionable problem rather than an empty summary nobody notices. + +```mermaid +flowchart TD + result["Health result (PRD-002a) + login state (PRD-002b)"] --> decide{"Compose state"} + decide -->|"all pass"| green["Healthy / Capturing"] + decide -->|"capture ok, summary path impaired"| degraded["Degraded"] + decide -->|"hooks/prereqs missing"| notcfg["Not configured"] + decide -->|"login invalid"| out["Logged out"] + decide -->|"can't determine"| unknown["Unknown / Offline"] + green --> bar["Status bar item"] + degraded --> bar + notcfg --> bar + out --> bar + unknown --> bar + bar -->|"click"| detail["Detail view + remediation actions"] +``` + +--- + +## Command palette surface (basic) + +A small, dependable set of commands. Each maps to logic owned by 002a/002b; this sub-feature contributes the palette entries and wires them. + +| Command (intent) | What it does | Delegates to | +|---|---|---| +| **Hivemind: Run Onboarding** | Re-runs the full guided setup (prereqs → auth → wiring). | PRD-002a + PRD-002b | +| **Hivemind: Log In** | Starts the browser device-flow or API-key entry. | PRD-002b | +| **Hivemind: Log Out** | Clears credentials with an honest summary of what's removed. | PRD-002b | +| **Hivemind: Show Status** | Opens the detail view with all dimensions and identity. | PRD-002a (result) | +| **Hivemind: Wire / Refresh Hooks** | Triggers idempotent (re)wiring. | PRD-002a | +| **Hivemind: Open Logs** | Opens the extension output channel / wiki-worker log location. | this sub-feature | + +The detail view mirrors what `hivemind status` reports on the CLI (`src/cli/index.ts` `runStatus`: version, "logged in: yes/no", detected assistants), so the editor and terminal tell the same story. + +--- + +## Presentation requirements + +- **One item, not many.** A single status-bar item; no clutter of separate icons per dimension. +- **Honest color/affordance.** Green is reserved for fully healthy. Degraded and not-configured are visually distinct from green and from each other. (No reliance on color alone; include a label/icon for accessibility.) +- **Tooltip first.** Hover surfaces a concise summary of all dimensions before the developer even clicks. +- **Prompt updates.** The item reflects a health change within one poll interval (interval owned by PRD-002a); a manual "refresh" is available. +- **No secret leakage.** The detail view and logs never render tokens or API keys (defers to PRD-002b's secrets rules). +- **Quiet by default.** At most one actionable nudge per newly-detected problem; the persistent item carries ongoing state without nagging. + +--- + +## Acceptance criteria + +| ID | Criterion | +|---|---| +| AC-1 | Given all health dimensions pass and login is valid, when the status item renders, then it shows the "Healthy / Capturing" state and the tooltip confirms all dimensions pass. | +| AC-2 | Given `cursor-agent` is logged out, when the status item renders, then it shows "Degraded" (not green) and clicking it surfaces the `cursor-agent` login remediation. | +| AC-3 | Given hooks are not wired, when the status item renders, then it shows "Not configured" and offers a "Run Onboarding" entry point. | +| AC-4 | Given the developer is not logged in to Hivemind, when the status item renders, then it shows "Logged out" and offers a "Log In" action. | +| AC-5 | Given health cannot be determined (offline), when the status item renders, then it shows "Unknown / Offline" rather than a false green or red. | +| AC-6 | Given any health dimension changes during a session, when the next poll completes, then the status item updates to the new state without requiring a window reload. | +| AC-7 | Given the command palette, when the developer opens it, then the six core Hivemind commands are present and each routes to its owning logic (002a/002b) or opens logs. | +| AC-8 | Given the detail view or logs are opened, when their contents are inspected, then no token or API key value appears anywhere. | + +--- + +## Open questions + +- [ ] Should the status item also reflect a transient "capturing now" pulse on hook activity, or only steady-state health? (Steady-state proposed for this stage.) +- [ ] What is the right click default: open the detail view, or jump straight to the top remediation when degraded? +- [ ] Where should "Open Logs" point given the wiki-worker writes to its own log file: a unified output channel, the raw log path, or both? + +--- + +## Related + +- [`prd-002-cursor-extension-core-index`](./prd-002-cursor-extension-core-index.md): parent module. +- [`prd-002a-health-check`](./prd-002a-health-check.md): produces the four-dimension health result this renders. +- [`prd-002b-auth-secrets`](./prd-002b-auth-secrets.md): produces the login state this renders and the auth actions the palette triggers. +- Source grounding: `src/cli/index.ts` (`runStatus`, the CLI status story the detail view mirrors), `src/hooks/cursor/wiki-worker.ts:186-188` (the silent failure this surface makes visible). 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 <name>`, 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<br/>(status bar click or command palette)"] --> health{"Healthy from PRD-002?"} + health -->|"No"| nudge["Show PRD-002 remediation inline<br/>(degraded / logged out / not configured)"] + nudge --> open + health -->|"Yes / Degraded"| shell["Webview opens with three panes"] + shell --> kpi["KPI + Sessions pane<br/>(PRD-003a)"] + shell --> settings["Settings + Environment pane<br/>(PRD-003b)"] + shell --> viewer["Session Summary + Next Steps pane<br/>(PRD-003c)"] + kpi --> explain{"KPI reads 0 or stale?"} + explain -->|"Yes"| why["Show why: no recalls yet,<br/>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/completed/prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md b/library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md new file mode 100644 index 00000000..cce1cc59 --- /dev/null +++ b/library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md @@ -0,0 +1,202 @@ +# PRD-003a: Live KPI & Session History Webview + +> **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 is the dashboard's front door: the pane a developer sees first when they open Hivemind inside Cursor. It does one job extremely well, **make the value of the shared brain visible and believable**. It renders a small set of KPI cards (tokens saved, sessions captured, active memory recalls, skills created) and a scannable list of recent sessions, drawn from the same read-only data layer the CLI dashboard already uses (`src/dashboard/data.ts:270-288`). It lives in a Cursor Webview, so the developer never opens an external browser or runs a command to see what Hivemind has done for them. + +The value is trust through visibility. After PRD-002, a developer knows Hivemind is *on*. This pane shows them it is *working*, and, crucially, it never lets a number lie by omission. The single hardest design problem here is not drawing a card; it is making a `0` or a stale value honest. The two ways Hivemind produces a confusing number, a stale cached snapshot and the accumulate-only-on-recall stats model, are both surfaced and explained here rather than left to scare the developer into thinking the product is broken. + +--- + +## Why this matters: the mystery zeros we are killing + +A developer who sees "0 tokens saved" after a morning of work concludes Hivemind is broken and stops trusting it. But the 0 is usually correct and benign, and the code already knows why. Two mechanisms produce it: + +**1. The stale cached snapshot.** Org-wide stats come from a daily server rollup and are cached locally for one hour. The cache deliberately returns a fresh value without re-fetching, and even returns a *stale* value when the network fails: + +```114:133:src/notifications/sources/org-stats.ts +function readCache(scopeKey: string): { fresh?: OrgStats; stale?: OrgStats } { + if (!existsSync(cacheFilePath())) return {}; + try { + const parsed = JSON.parse(readFileSync(cacheFilePath(), "utf-8")) as CacheFileShape; + if (!parsed || typeof parsed !== "object") return {}; + if (parsed.scopeKey !== scopeKey) return {}; + if (typeof parsed.fetchedAt !== "number") return {}; + 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 }; + return { stale: data }; + } catch (e: any) { + log(`cache read failed: ${e?.message ?? String(e)}`); + return {}; + } +} +``` + +So a developer can be looking at a snapshot up to an hour old (or older, if offline). That is fine for a daily rollup, but only if the UI says so. + +**2. Accumulate-only-on-recall.** Local stats are written per session, and the load-bearing metric is *bytes of memory grep'd during that session*. A session that did not search memory contributes nothing: + +```21:33:src/notifications/usage-tracker.ts +export interface UsageRecord { + /** ISO 8601 timestamp the session ended. */ + endedAt: string; + /** Agent session_id (Claude Code session UUID). */ + sessionId: string; + /** Bytes of `tool_result.content` returned from Bash tool calls grep'ing + * `~/.deeplake/memory/` during this session ... */ + memorySearchBytes: number; + /** Count of Bash tool calls that referenced `.deeplake/memory` ... */ + memorySearchCount: number; +} +``` + +So "tokens saved" genuinely grows only when memory is recalled, not merely when a session happens. A quiet day is a real 0, not a bug. This pane's job is to make that legible: a `0` accompanied by "no memory recalls captured yet, this grows each time an agent pulls from your shared memory" is reassuring; a bare `0` is alarming. + +The data layer already hands us the distinction we need to tell these stories apart. + +--- + +## Goals + +- Render KPI cards for tokens saved, sessions captured, active memory recalls, and skills created, inside a Cursor Webview, with no external browser or CLI invocation. +- Disclose, for every KPI, its **source** (org rollup, local fallback, or none) and its **freshness** (the snapshot age), so a number is never presented as more authoritative than it is. +- Distinguish, visibly, between "0 because nothing has accumulated yet" and "no data source at all (fresh install)," and explain how each KPI grows. +- Offer an explicit refresh that re-loads the data and (per the index's open question) can request fresher org stats than the 1-hour cache would otherwise serve. +- Render a recent-sessions list (most recent first) with enough metadata to recognize a session and open it in the session viewer (PRD-003c). +- Never throw: a fresh install with no creds, no sessions, and no org data renders a coherent empty state, matching the data layer's never-throw contract (`src/dashboard/data.ts:264-288`). + +## Non-Goals + +- **Computing or rolling up stats.** The numbers come from the existing data layer and server rollup. This pane reads and explains; it does not aggregate (`src/dashboard/data.ts:211-262`). +- **Changing the stats cache or its TTL.** The 1-hour cache and daily rollup are upstream (`src/notifications/sources/org-stats.ts:14-35`). This pane discloses freshness; it does not re-architect caching. +- **Rendering the codebase graph visualization.** Graph build status and counts belong to the settings pane (PRD-003b) and the CLI dashboard's force-directed view (`src/dashboard/render.ts`); this pane is KPIs and sessions. +- **Opening or rendering full summaries.** Reading a summary and its Next Steps is PRD-003c. This pane lists sessions and hands off. +- **Settings.** Toggling embeddings, graph, or org is PRD-003b. This pane may deep-link to settings when a KPI is empty because a feature is off, but it owns no toggles. + +--- + +## The KPI cards + +Each card reuses a field the data layer already produces. The table is the contract between the data envelope and the rendered card. + +| Card | Value field | Source disclosure | Empty / zero story | +|---|---|---|---| +| **Tokens saved** | `kpis.tokensSaved` (org or local), with `kpis.userTokensSaved` as the "your contribution" sub-line | `kpis.tokensSource`: `"org"` => "team-wide, as of <age>"; `"local"` => "this machine"; `"none"` => empty | `null` + `"none"` => fresh-install empty state. `0` with a real source => "no memory recalls captured yet; this grows when agents pull from shared memory." | +| **Sessions captured** | `kpis.sessionsCount` | Same source as tokens (org rollup vs local record count) | `null` => "no sessions captured yet"; a positive number is always truthful. | +| **Active memory recalls** | `kpis.memorySearches` | Org `memoryRecallCount` or local `memorySearchCount` | `0` => "no recalls yet today; recalls happen when an agent greps your shared memory during a session." | +| **Skills created** | `kpis.skillsCreated` | Always local (directories under `~/.claude/skills/<name>--<author>/`) | `0` => "no skills authored yet"; note it counts only skills pulled to this machine (`src/notifications/usage-tracker.ts:118-169`). | + +> The `tokensSource` three-way distinction (`"org" | "local" | "none"`, `src/dashboard/data.ts:61`) is the spine of honest rendering. `"none"` means `tokensSaved` is `null`, which the renderer must treat as "empty install," never as "0 saved" (`src/dashboard/data.ts:254-262`). + +--- + +## Freshness and the refresh affordance + +Freshness is a first-class part of every org-sourced card, not a footnote. + +1. **Stamp the age.** When `tokensSource` is `"org"`, the card shows when the snapshot was taken, derived from the cache's `fetchedAt` (`src/notifications/sources/org-stats.ts:80-91,143`). "As of 12 minutes ago" turns a confusing stale number into an understood one. +2. **Explain the rollup cadence.** A tooltip or sub-line notes that org stats refresh from a daily server rollup, so same-minute precision is not expected. This matches the cache's own rationale ("the server rollup runs daily, so per-session freshness isn't meaningful," `src/notifications/sources/org-stats.ts:14-19`). +3. **Offer refresh.** A refresh control re-runs `loadDashboardData`. Per the index's open question, an explicit refresh may request fresher org stats than the 1-hour cache serves; the implementation must avoid hammering `/me/hivemind-stats` on rapid clicks (debounce or disable while in-flight). +4. **Be honest when offline.** If the org fetch fails and only a stale cache exists, the data layer returns the stale value (`src/notifications/sources/org-stats.ts:187,202-204`); the card must label it "offline, showing last known" rather than implying it is current. + +--- + +## The recent-sessions list + +The list turns the abstract "sessions captured" count into recognizable rows the developer can act on. + +- **Ordering.** Most recent first, matching the durable per-session record stream (`src/notifications/usage-tracker.ts:5-8`). +- **Per-row metadata.** Enough to recognize a session: ended-at timestamp, project, and a memory-recall indicator (whether this session actually pulled from memory, derived from `memorySearchCount > 0`). This directly visualizes the accumulate-only-on-recall model, a row that recalled memory contributed to "tokens saved"; a row that did not, did not. +- **Open action.** Each row links to PRD-003c's session viewer for that `sessionId`. +- **Empty state.** No sessions yet renders the same coherent empty state as the cards, with a one-line "your sessions will appear here as you work" hint, not a blank list. + +--- + +## The pane's data flow + +```mermaid +sequenceDiagram + participant Dev as Developer + participant Web as KPI Webview pane + participant Data as "loadDashboardData()" + participant Org as "fetchOrgStats (1h cache)" + participant Local as "usage-stats.jsonl" + + Dev->>Web: Open dashboard + Web->>Data: load (cwd, creds) + Data->>Org: fetch org stats (if creds) + alt Org reachable + fresh cache + Org-->>Data: org scope + fetchedAt + Data-->>Web: tokensSource="org" + snapshot age + else Org unreachable, stale cache exists + Org-->>Data: stale org scope + Data-->>Web: tokensSource="org" + "offline / last known" + else No org data + Data->>Local: read usage records + alt records exist + Local-->>Data: local sums + Data-->>Web: tokensSource="local" + else none + Data-->>Web: tokensSource="none" (empty state) + end + end + Web-->>Dev: KPI cards + sessions, each with source + freshness + Dev->>Web: Click Refresh + Web->>Data: reload (debounced) +``` + +--- + +## Presentation requirements + +- **Beautiful and native-feeling.** The pane respects Cursor's theme (light/dark), uses the editor's font and color tokens, and reads as a first-party surface, not an embedded webpage. +- **Source and freshness always visible.** No card shows a number without its source label; org-sourced cards always show snapshot age. +- **Zero is never bare.** Any `0` or `null` carries its one-line explanation of how the metric grows or why it is empty. +- **No secret leakage.** The serialized state payload handed to the Webview and any logs never include tokens or API keys; identity appears as user/org name only (defers to PRD-002b). +- **Responsive to refresh.** Refresh shows an in-flight state and updates in place; it never blanks the pane while loading. +- **Accessible.** KPI status is conveyed by label and number, not color alone; the recent-sessions list is keyboard-navigable. + +--- + +## Acceptance criteria + +| ID | Criterion | +|---|---| +| AC-1 | Given a logged-in developer with org stats available, when the KPI pane renders, then each org-sourced card shows its value, a "team-wide" source label, and the snapshot age. | +| AC-2 | Given `tokensSource` is `"none"` (fresh install, no creds or sessions), when the pane renders, then the tokens card shows a fresh-install empty state and never the literal "0 saved." | +| AC-3 | Given a real data source but zero recalls, when the tokens or recalls card renders `0`, then it shows the explanation of how the metric accumulates (only on memory recalls) rather than a bare `0`. | +| AC-4 | Given the org stats are served from cache, when the card renders, then it stamps the snapshot age derived from the cache `fetchedAt`. | +| AC-5 | Given the org fetch fails but a stale cache exists, when the pane renders, then the card is labeled "offline / last known" rather than presented as current. | +| AC-6 | Given the developer clicks Refresh, when the reload is in flight, then the control is debounced/disabled so rapid clicks do not issue multiple concurrent org fetches, and the pane updates in place on completion. | +| AC-7 | Given captured sessions exist, when the recent-sessions list renders, then sessions appear most-recent-first with ended-at, project, and a memory-recall indicator, and each row can open the session viewer (PRD-003c). | +| AC-8 | Given no sessions exist, when the list renders, then it shows a coherent empty state, not a blank or broken list. | +| AC-9 | Given the Webview state payload or logs are inspected, when their contents are examined, then no token or API key value appears anywhere. | + +--- + +## Open questions + +- [ ] Should "active memory recalls" headline the org-wide recall count or the per-user recall count by default, given org stats expose both (`OrgStatsScope`, `src/notifications/sources/org-stats.ts:43-47`)? +- [ ] What exact freshness phrasing avoids false precision for a daily rollup ("as of 12 minutes ago" could imply live data)? Possibly "rolled up daily, last synced 12 minutes ago." +- [ ] For the recent-sessions list, is the local `usage-stats.jsonl` stream sufficient (zero-latency but local-only), or do we need a remote sessions-table query for cross-machine completeness (slower, matches `src/hooks/cursor/wiki-worker.ts:116-142`)? +- [ ] Should an explicit refresh hard-bypass the 1-hour org-stats cache, and if so, do we need a minimum interval between forced refreshes to protect the endpoint? + +--- + +## Related + +- [`prd-003-cursor-extension-dashboard-index`](./prd-003-cursor-extension-dashboard-index.md): parent module. +- [`prd-003b-settings-manager`](./prd-003b-settings-manager.md): owns the toggles this pane deep-links to when a KPI is empty because a feature is off. +- [`prd-003c-session-viewer`](./prd-003c-session-viewer.md): opens a session row from the recent-sessions list. +- [`../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 opens from. +- Source grounding: `src/dashboard/data.ts:61-262` (`DashboardKpis`, `tokensSource`, `loadKpis` three-way fallback), `src/dashboard/data.ts:264-288` (never-throw load), `src/notifications/sources/org-stats.ts:14-19,80-133,162-208` (1-hour cache, `fetchedAt`, stale-on-failure), `src/notifications/usage-tracker.ts:21-116` (per-session local stats, accumulate-only-on-recall), `src/commands/dashboard.ts:36-68` (the read-only CLI surface this pane supersedes in-editor). 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 <name>`) 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/<repo-key>/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 <name-or-id>` (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<br/>(writeUserConfig / graph build / org switch)"] + invoke --> persisted{"Persisted OK?"} + persisted -->|"No"| failstate["Show failure; config unchanged; offer retry"] + persisted -->|"Yes"| sideeffects["Apply side effects<br/>(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/completed/prd-003-cursor-extension-dashboard/prd-003c-session-viewer.md b/library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003c-session-viewer.md new file mode 100644 index 00000000..e270faed --- /dev/null +++ b/library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003c-session-viewer.md @@ -0,0 +1,154 @@ +# PRD-003c: Session Summary & Next Steps Viewer + +> **Status:** Backlog +> **Priority:** P2 +> **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 closes the loop. Hivemind's whole purpose is to turn the work of one session into reusable memory for the next, and the most concrete artifact of that is the **session summary**: a markdown document the wiki worker generates at session end and uploads to the shared memory table (`src/hooks/cursor/wiki-worker.ts:190-219`). Every summary ends with a curated `## Next Steps` block, a contract so important it is byte-locked across all agent variants in test (`tests/claude-code/wiki-next-steps-contract.test.ts:11-19`). Today those summaries live in remote storage and the developer never sees them in the editor, and those Next Steps, the most actionable thing the brain produces, evaporate instead of becoming the developer's next task. + +This pane brings summaries into Cursor. The developer picks a session (from the recent-sessions list in PRD-003a), reads its summary rendered in-editor, and converts any "Next Step" into a real Hivemind goal or a Cursor task with one click. The value is a brain that does not just remember, but actively hands the developer their next move. + +--- + +## Why this matters + +The summary is the payoff of the entire capture pipeline, and right now it is invisible and inert. + +1. **Invisible.** Summaries are uploaded to `/summaries/<userName>/<sessionId>.md` in the memory table (`src/hooks/cursor/wiki-worker.ts:194-219`) and surfaced only indirectly, in the SessionStart brief or the resume brief. A developer who wants to read what their last session accomplished has no in-editor reader. +2. **Inert Next Steps.** Each summary ends with a deliberately-formatted `## Next Steps` section. The contract test exists precisely because these next steps feed downstream surfaces and must stay byte-stable (`tests/claude-code/wiki-next-steps-contract.test.ts:11-19,96`). Yet there is no path for a developer to act on a Next Step beyond reading it; it does not become a goal or a task. +3. **A disconnected goals system.** Hivemind already has a goals concept, open goals are read and surfaced in the SessionStart banner (`src/notifications/sources/open-goals.ts:42-85`) and managed via `hivemind goal` (`src/cli/index.ts:447-450`). The Next Steps in a summary are exactly the raw material for goals, but nothing connects the two. + +This pane connects reading to acting: summary in, goal or task out. + +--- + +## Goals + +- Render a selected session's summary markdown inside the Cursor dashboard Webview, including its `## Next Steps` block, with no external browser. +- Parse the `## Next Steps` section reliably (honoring its locked contract format) and present each next step as an actionable item. +- Let the developer convert a Next Step into a Hivemind goal (via the canonical goals path) or a Cursor task, with one click per item. +- Reflect a newly created goal back in the dashboard (the KPI/goals surfaces) so the loop is visibly closed. +- Handle the realities of summaries gracefully: a session with no summary yet (the silent-failure case PRD-002 guards against), a summary with an empty Next Steps section, and an unreachable memory table all render honest states. + +## Non-Goals + +- **Generating or editing summaries.** Summary generation is the wiki worker's job (`src/hooks/cursor/wiki-worker.ts`); this pane reads, it does not author or mutate stored summaries. +- **A full memory browser or search.** Browsing the entire memory corpus or free-text search across summaries is a later stage. This pane opens a specific session's summary handed to it by PRD-003a. +- **Goal management UI.** Editing, closing, or reprioritizing goals is the goals system's concern (`hivemind goal`). This pane only *creates* goals/tasks from Next Steps and links out. +- **Re-running summarization for a session.** Triggering a re-summary or backfill is out of scope; this pane reflects what the worker has already produced. +- **Cross-agent summary aggregation.** This pane is Cursor-scoped and reads Cursor session summaries; multi-agent summary unification is not part of this stage. + +--- + +## What the pane shows + +```mermaid +flowchart TD + pick["Developer selects a session<br/>(from PRD-003a recent list)"] --> fetch["Fetch summary for sessionId<br/>(/summaries/<user>/<sessionId>.md)"] + fetch --> exists{"Summary exists?"} + exists -->|"No"| empty["Honest empty state:<br/>'No summary yet for this session'<br/>(+ PRD-002 cursor-agent health hint if degraded)"] + exists -->|"Yes"| render["Render summary markdown in-editor"] + render --> parse["Parse ## Next Steps block"] + parse --> hasSteps{"Next Steps present?"} + hasSteps -->|"No"| noSteps["Show summary; note 'no next steps captured'"] + hasSteps -->|"Yes"| steps["List each Next Step as an actionable item"] + steps --> act{"Developer acts on a step"} + act -->|"Make goal"| goal["Create Hivemind goal (canonical goal path)"] + act -->|"Make Cursor task"| task["Create Cursor task (if available)"] + goal --> reflect["Dashboard reflects new open goal"] + task --> reflect +``` + +The pane has three regions: a header identifying the session (project, ended-at, source path), the rendered summary body, and an actionable Next Steps list. + +--- + +## Reading the summary + +- **Source.** Summaries are stored at `/summaries/<userName>/<sessionId>.md` in the memory table; the pane fetches by the `sessionId` handed in from PRD-003a's recent-sessions list. The wiki worker is the authoritative writer of this path (`src/hooks/cursor/wiki-worker.ts:194-195`). +- **Render markdown natively.** The summary is markdown and must render with headings, lists, and code blocks intact in the Webview, theme-aware. +- **Show provenance.** The header shows where the summary came from (session id, project, ended-at) so the developer trusts what they are reading. +- **The no-summary state is meaningful.** A missing summary is exactly the silent-failure symptom PRD-002 attacks: when `cursor-agent` was logged out, summaries became empty placeholders (`src/hooks/cursor/wiki-worker.ts:186-188,228-230`). If a selected session has no summary and PRD-002 health is degraded for that reason, this pane shows the connection ("this session has no summary; `cursor-agent` was logged out, see status bar") rather than a blank. + +--- + +## The Next Steps contract + +The `## Next Steps` block is not free-form text; it is a locked contract, and this pane must honor it. + +- **Stable marker.** The section begins with the literal `## Next Steps` heading; the contract test extracts the body from that marker to the next section (`tests/claude-code/wiki-next-steps-contract.test.ts:41-54`). +- **Byte-identical across agents.** The block is required to be byte-identical across all five agent copies (`tests/claude-code/wiki-next-steps-contract.test.ts:96`), so a single parser works for Cursor and every other agent's summaries. +- **Parse defensively.** Each line item becomes an actionable entry. The parser must tolerate an empty or absent section (the contract allows a "no next steps" consequence) and never crash on a malformed summary, matching Hivemind's fail-soft posture everywhere else. + +> Because the format is contract-locked and tested, the parser can be strict about the marker and lenient about the contents, the safest combination. + +--- + +## Converting a Next Step into action + +Two promotion targets, one click each. + +| Action | What it creates | Delegates to | Result reflected in | +|---|---|---|---| +| **Make a goal** | A Hivemind goal whose label is the Next Step text | The canonical goal path (`hivemind goal`, `src/cli/index.ts:447-450`); goals are read back by `listOpenGoals` (`src/notifications/sources/open-goals.ts:60-65`) | The dashboard's open-goals surface and the SessionStart banner. | +| **Make a Cursor task** | A task in Cursor's task system, if a first-party API is available | Cursor task API (open question); falls back to "Make a goal" when unavailable | Cursor's task surface. | + +Requirements: + +1. **Canonical goal creation.** A promoted goal is created through the same path the CLI uses, so it appears in the same owner-scoped, version-deduped reader that powers the banner (`src/notifications/sources/open-goals.ts:55-65`). The pane does not invent a parallel goals store. +2. **Visible confirmation.** After promotion, the item shows a "created" state and the dashboard's open-goals count reflects the new goal, so the developer sees the loop close (matches PRD-003 module AC-6). +3. **Idempotency guardrail.** Promoting the same Next Step twice should not silently create duplicate goals without acknowledgment; the pane warns or marks already-promoted steps. +4. **Graceful fallback.** If Cursor task creation is unavailable, the pane offers "Make a goal" instead of failing, so the developer always has a working action. + +--- + +## Presentation requirements + +- **Readable summary.** Markdown renders cleanly and theme-aware; long summaries scroll within the pane without breaking the dashboard layout. +- **Actionable, not just readable.** Next Steps are visually distinct from the summary body and carry their promotion buttons inline. +- **Honest empty and error states.** No summary, empty Next Steps, and an unreachable memory table each render a specific, calm message, never a blank pane or a stack trace. +- **No secret leakage.** Summary content, the session header, and any logs never render tokens or API keys; the fetch authorizes via the existing credential path without exposing the token (defers to PRD-002b). +- **Loop visibility.** A promoted Next Step produces immediate, visible feedback and updates the dashboard's goals surface. + +--- + +## Acceptance criteria + +| ID | Criterion | +|---|---| +| AC-1 | Given a session with an uploaded summary, when the developer opens it in the viewer, then the summary markdown renders in-editor with headings, lists, and code blocks intact. | +| AC-2 | Given a summary with a populated `## Next Steps` section, when the viewer renders, then each next step appears as a distinct, actionable item parsed from the contract-locked block. | +| AC-3 | Given a Next Step, when the developer chooses "Make a goal," then a Hivemind goal is created via the canonical goal path and appears in the dashboard's open-goals surface. | +| AC-4 | Given a Next Step and an available Cursor task API, when the developer chooses "Make a Cursor task," then a Cursor task is created; given no such API, the viewer offers "Make a goal" instead and does not fail. | +| AC-5 | Given a session that has no summary, when the viewer opens it, then it shows an honest "no summary yet" state, and if PRD-002 health is degraded due to a logged-out `cursor-agent`, it links that cause. | +| AC-6 | Given a summary whose Next Steps section is empty or absent, when the viewer renders, then the summary still shows and the pane notes "no next steps captured" without error. | +| AC-7 | Given the developer promotes the same Next Step twice, when the second promotion is attempted, then the pane marks it already-promoted or warns rather than silently creating a duplicate. | +| AC-8 | Given the memory table is unreachable, when the viewer tries to fetch a summary, then it shows a calm error state and does not crash the dashboard. | +| AC-9 | Given the rendered summary, the session header, or logs are inspected, when their contents are examined, then no token or API key value appears anywhere. | + +--- + +## Open questions + +- [ ] Does Cursor expose a first-party API to create a task/todo the viewer can target, or should promotion default to Hivemind goals with Cursor tasks as best-effort (the index PRD raises this)? +- [ ] Should summaries be fetched from the remote memory table directly (authoritative, matches the worker's storage at `src/hooks/cursor/wiki-worker.ts:194-195`) or from a local cache for latency, and how fresh must they be? +- [ ] How should a resumed-session summary (which carries a `**JSONL offset**` marker, `src/hooks/cursor/wiki-worker.ts:152-154`) be presented, as one evolving document or with revision awareness? +- [ ] What dedupe key prevents duplicate goals from repeated promotion, the Next Step text, a hash, or a `(sessionId, stepIndex)` tuple? +- [ ] Should the viewer allow lightly editing a Next Step before promoting it to a goal, or promote verbatim to preserve the brain's wording? + +--- + +## 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): the recent-sessions list that selects which session this viewer opens, and the goals surface a promoted Next Step updates. +- [`prd-003b-settings-manager`](./prd-003b-settings-manager.md): 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 `cursor-agent` login (D3) check this viewer references when a summary is missing. +- [`../prd-002-cursor-extension-core/prd-002c-status-bar.md`](../prd-002-cursor-extension-core/prd-002c-status-bar.md): the status bar this viewer points to when a missing summary traces to degraded health. +- Source grounding: `src/hooks/cursor/wiki-worker.ts:186-230` (summary generation, upload path `/summaries/<user>/<sessionId>.md`, the missing-summary silent failure), `tests/claude-code/wiki-next-steps-contract.test.ts:11-96` (the locked `## Next Steps` contract this pane parses), `src/notifications/sources/open-goals.ts:42-85` (canonical goals reader a promoted step feeds), `src/cli/index.ts:447-450` (`hivemind goal` command the promotion delegates to). 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 `<pre class="summary">` 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 `<pre>` 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/<user>/<id>.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/completed/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 new file mode 100644 index 00000000..219873e2 --- /dev/null +++ b/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004-cursor-graph-visualizer-index.md @@ -0,0 +1,182 @@ +# PRD-004: Interactive Codebase Graph Visualizer + +> **Status:** Backlog +> **Priority:** P2 +> **Effort:** XL (> 3d) +> **Schema changes:** None + +--- + +## Overview + +PRD-002 made Hivemind honest inside Cursor (a status bar that never lies, zero-friction onboarding). PRD-003 made it visible and controllable (live KPI cards, a graphical settings panel, an in-editor session viewer). Both stages deliberately drew the same boundary line: the codebase graph stays a background, text-only asset. PRD-003 surfaces graph build status and counts and can trigger a build, but it explicitly defers "a rich interactive codebase-graph explorer" to a later stage (`prd-003-cursor-extension-dashboard-index.md` non-goals; `prd-003a-kpi-webview.md` non-goals; `prd-002c-status-bar.md` non-goals). PRD-004 is that stage. + +Hivemind already has a rich codebase graph. A multi-language extraction pipeline parses TypeScript, JavaScript, Python, Go, Rust, Java, Ruby, C, and C++ into a NetworkX-compatible directed multigraph of files, classes, functions, methods, and their `imports`/`calls`/`extends`/`implements`/`method_of` relationships (`src/graph/types.ts:23-179`). That graph is persisted as deterministic, content-addressed snapshots on disk (`src/graph/snapshot.ts:196-252`) and is already consumed by agents through a text virtual filesystem with eight query endpoints (`src/graph/vfs-handler.ts:56-160`). The one consumer it has never had is the developer's own eyes. The graph is something agents read; it is not something humans see. + +PRD-004 delivers the **Interactive Codebase Graph Visualizer**: a visual, explorable, force-directed rendering of that exact same snapshot, embedded as a first-class view inside the PRD-003 dashboard Webview. It does three things. It **shows the shape of the code**: an interactive 2D/3D node-link graph where files, classes, and functions become nodes and imports and calls become edges, styled by the metadata the pipeline already computes (`fan_in`, `fan_out`, `is_entrypoint`, `exported`, `src/graph/node-metadata.ts:18-32`). It **fuses the graph with the editor**: clicking a node opens its file and jumps to the precise line of its AST declaration, and moving the cursor in the editor highlights the active node in the graph. And it **makes change consequences visible before they happen**: when a developer has unstaged edits, the visualizer highlights the affected neighborhood, the transitive set of dependents that could feel the ripple, reusing the same reverse-dependency traversal the text `impact/` endpoint already performs (`src/graph/render/impact.ts:22-113`). + +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 has onboarded (PRD-002) and uses the dashboard (PRD-003). They have run a graph build, so a snapshot exists on disk. Their experience of that graph today has a hard ceiling: + +1. **The graph is invisible to them.** Its value is real but indirect: agents read it through the text VFS to answer "who calls this?" or "what is the blast radius?" (`src/graph/vfs-handler.ts:99-160`). The developer paid for a build and got a number ("1,240 nodes, 3,800 edges") but no way to actually look at their codebase's structure. +2. **The only visual rendering lives outside the editor.** The single force-directed view that exists is generated by the CLI dashboard as a self-contained HTML file written to disk and opened in an external browser (`src/dashboard/render.ts`, invoked by `src/commands/dashboard.ts:36-68`). It is read-only, it is disconnected from the editor, and it cannot answer "take me to that function." +3. **Exploring structure means reading text dumps.** To understand a file's neighborhood or a symbol's dependents, the developer (or their agent) reads padded, capped text tables (`renderNeighborhood`, `src/graph/render/neighborhood.ts:13-160`; `renderImpact`, `src/graph/render/impact.ts:75-112`). Text is excellent for an agent's context window and terrible for a human trying to grasp shape, clusters, and hot spots at a glance. +4. **Change is a leap of faith.** Before editing a heavily-depended-on function, a developer has no in-editor picture of what depends on it. The information exists (the reverse-dependency BFS is already implemented, `src/graph/render/impact.ts:47-69`), but it is one CLI text query away, phrased as a symbol pattern, not a living highlight over the code they are about to touch. + +Every one of these is a place where Hivemind has already done the hard work (building and resolving the graph) but stops short of letting the developer benefit visually. PRD-004 closes that final gap: it turns an agent-only asset into a developer-facing, editor-fused, change-aware map. + +--- + +## Value & success themes + +| Theme | What "good" feels like for the developer | +|---|---| +| **See your codebase** | One view shows the whole structure: clusters of related files, hub functions everyone depends on, isolated leaves. The shape that was implicit in the code becomes explicit and explorable. | +| **The graph and the editor are one surface** | Clicking a node lands the cursor on the exact declaration line; moving the cursor lights up where you are in the graph. The map and the territory stay in sync, both directions, automatically. | +| **Know the blast radius before you leap** | Before changing a function, the developer sees its dependents highlighted over their own working changes, so "what will this break?" is answered visually instead of guessed. | +| **One graph, many readers** | The visualizer renders the exact same snapshot agents query; the picture a human sees and the context an agent reads can never disagree, because they are the same bytes. | +| **Honest about its limits** | Where cross-file resolution is partial, the visualizer says so. An empty incoming set is shown as "no resolved dependents," never as a false promise of dead code. | + +--- + +## Goals + +- A developer can open an interactive, force-directed graph of their codebase inside the Cursor dashboard Webview, rendered from the same on-disk snapshot the agent VFS reads (`src/dashboard/data.ts:141-209`), without an external browser or a CLI command. +- The visualizer represents the real graph entities the pipeline produces: nodes for files/classes/functions/methods (`NodeKind`, `src/graph/types.ts:127-136`) and edges for `imports`/`calls`/`extends`/`implements`/`method_of` (`EdgeRelation`, `src/graph/types.ts:169-179`), styled by the derived metadata (`fan_in`/`fan_out`/`is_entrypoint`/`exported`). +- Clicking any node opens its source file in Cursor and moves the cursor to the exact line encoded in `source_location` (`src/graph/types.ts:99-102`); conversely, moving the cursor in an open editor highlights the corresponding node in the graph. +- When the developer has unstaged changes, the visualizer highlights the affected neighborhood, the transitive dependents of the changed symbols, reusing the reverse-dependency traversal that powers the text `impact/` endpoint (`src/graph/render/impact.ts:47-69`). +- The visualizer integrates with the existing dashboard lifecycle: it lives as a view inside the PRD-003 Webview shell, refreshes when PRD-003b triggers a graph build, and reflects graph presence/absence consistently with the PRD-002c status bar. +- The visualizer degrades gracefully: no snapshot, a stale snapshot, an unsupported-size graph, or a parse failure each render a coherent, explained state rather than a crash or a blank canvas (matching the never-throw posture of both the data layer, `src/dashboard/data.ts:141-209`, and the VFS handler, `src/graph/vfs-handler.ts:164-216`). + +## Non-Goals + +- **Building or extracting the graph.** Extraction (`src/graph/extract/`), snapshot construction (`src/graph/snapshot.ts`), and cross-file resolution (`src/graph/resolve/cross-file.ts`) are upstream and unchanged. PRD-004 reads snapshots; it never produces them. +- **Re-implementing graph queries.** The traversals (impact, neighborhood, layers, path, tour) already exist as deterministic renderers (`src/graph/render/`). The visualizer consumes the same snapshot and reuses the same traversal logic; it does not invent a parallel query engine. +- **Changing the snapshot schema or storage.** The NetworkX node-link shape (`src/graph/types.ts:23-41`), the content-hash contract, and the on-disk layout (`src/graph/snapshot.ts:196-252`) are fixed inputs. Schema changes are explicitly out of scope (Schema changes: None). +- **Triggering or owning graph builds.** Build/refresh is owned by PRD-003b's settings manager (`prd-003b-settings-manager.md`), which invokes `hivemind graph build` (`src/cli/index.ts:462-465`). The visualizer requests a refresh through that surface; it does not own the build. +- **A semantic / similarity graph.** The current graph is AST-based with no semantic-similarity edges (`src/graph/vfs-handler.ts:297-304`). Embedding-based "related code" overlays are a later stage. +- **Editing code from the graph.** The visualizer navigates to and highlights code; it does not rename, move, or refactor symbols from the canvas. Editing happens in the editor. +- **A VS Code (non-Cursor) release.** The target surface is Cursor 1.7+, matching PRD-002 and PRD-003. + +--- + +## Sub-features + +| Sub-PRD | Scope | Status | +|---|---|---| +| [`prd-004a-graph-webview`](./prd-004a-graph-webview.md) | The Interactive Graph Webview: render the snapshot as a force-directed 2D/3D node-link graph inside the dashboard Webview, with filtering (by layer, kind, relation), metadata-driven styling, level-of-detail for large graphs, and honest loading/empty/no-graph states. | Backlog | +| [`prd-004b-editor-sync`](./prd-004b-editor-sync.md) | Editor Sync and Navigation: click a node to open its file and jump to the exact AST line (`source_location`), and reflect the editor's active cursor position back as the highlighted node in the graph, bidirectionally. | Backlog | +| [`prd-004c-impact-visualizer`](./prd-004c-impact-visualizer.md) | The Change Impact Visualizer: map the developer's unstaged changes to graph nodes, highlight the transitive dependent neighborhood (blast radius) over the graph, and disclose the lower-bound honesty caveat. | Backlog | + +--- + +## The visualizer journey (module-level) + +The three sub-features compose into one continuous experience inside the dashboard's graph view. The extension owns the Webview shell and routing (inherited from PRD-003); each sub-PRD owns a capability layered on the same rendered graph. + +```mermaid +flowchart TD + open["Developer opens the Graph view<br/>(dashboard tab or command palette)"] --> haveGraph{"Snapshot on disk?<br/>(resolveSnapshot)"} + haveGraph -->|"No"| empty["Honest empty state:<br/>'No graph built yet' + Build action (PRD-003b)"] + empty --> open + haveGraph -->|"Yes"| render["Render force-directed node-link graph<br/>(PRD-004a)"] + render --> explore["Developer explores:<br/>filter by layer / kind / relation, zoom, pan"] + explore --> clickNode{"Interaction?"} + clickNode -->|"Click a node"| jump["Open file + jump to source_location line<br/>(PRD-004b)"] + clickNode -->|"Move cursor in editor"| highlight["Highlight active node in graph<br/>(PRD-004b)"] + clickNode -->|"Has unstaged changes"| impact["Highlight affected neighborhood<br/>(blast radius, PRD-004c)"] + jump --> explore + highlight --> explore + impact --> decide["Developer understands ripple,<br/>edits with confidence"] +``` + +The defining property carried from PRD-002 and PRD-003: **the surface never leaves the developer guessing.** Where the legacy path would force a CLI text query or an external browser, the visualizer shows the structure, the location, and the consequences in-editor, and is honest wherever the underlying graph is. + +--- + +## Personas + +| Persona | Context | What PRD-004 gives them | +|---|---|---| +| **The newcomer (Dana)** | Joined a large repo last week; does not yet have a mental model of how it fits together. | A visual map that reveals subsystems, hubs, and entrypoints at a glance, with one click to jump into any of them. | +| **The navigator (Marco)** | Lives in the editor; resents context-switching to a browser to understand structure. | A graph fused with his cursor: where he is in the code is where he is in the graph, and vice versa, with no window-switching. | +| **The careful refactorer (Priya)** | About to change a core utility and is afraid of unseen consumers. | A highlighted blast radius over her unstaged changes showing exactly which dependents the edit could reach. | +| **The skeptic (Lee)** | Distrusts tools that overstate certainty. | A visualizer that labels partial cross-file resolution honestly, so an empty dependent set never reads as a false "safe to delete." | +| **The architect (Sam)** | Wants to see whether the codebase's real structure matches the intended layering. | A layer-grouped view (Tests, Hooks, CLI, Graph, and so on, `src/graph/render/layers.ts:9-19`) that exposes cross-layer edges that should not exist. | + +--- + +## Acceptance criteria (module-level) + +| ID | Criterion | +|---|---| +| AC-1 | Given a repo with a built snapshot, when the developer opens the Graph view, then a force-directed node-link graph renders inside the Cursor dashboard Webview from the same snapshot the agent VFS reads, with no external browser. | +| AC-2 | Given no snapshot exists for the repo, when the Graph view opens, then it shows a coherent "no graph yet" state with a Build action that routes to PRD-003b, never a blank canvas or a crash. | +| AC-3 | Given a rendered graph, when the developer clicks a node, then Cursor opens the node's `source_file` and moves the cursor to the line encoded in `source_location`. | +| AC-4 | Given an open editor whose file and cursor correspond to a node, when the developer moves the cursor onto that symbol, then the matching node is highlighted in the graph within one sync interval. | +| AC-5 | Given the developer has unstaged changes, when they enable the impact overlay, then the changed symbols and their transitive dependents are highlighted, computed by the same reverse-dependency traversal as the text `impact/` endpoint. | +| AC-6 | Given the impact overlay is shown, when dependents are highlighted, then the view discloses that cross-file resolution is partial and the highlighted set is a lower bound, not a completeness guarantee. | +| AC-7 | Given a graph build is triggered from PRD-003b, when the build completes, then the open Graph view refreshes to the new snapshot without the developer reopening the Webview. | +| AC-8 | Given a snapshot whose size exceeds the smooth-rendering threshold, when the Graph view opens, then it applies a level-of-detail or filtered initial view (and says so) rather than freezing the editor. | +| AC-9 | Given a malformed or partially-written snapshot, when the Graph view loads it, then it shows an explained error state consistent with the data layer's never-throw contract, not a stack trace. | +| AC-10 | Given the Graph view, its serialized payload, or its logs are inspected, when their contents are examined, then no token or API key value appears (the snapshot read path is local-only, `src/graph/vfs-handler.ts:20-22`). | + +--- + +## How PRD-004 reuses what already exists (cross-cutting) + +PRD-004's central discipline is consumption, not reinvention. Every visual capability maps to an artifact the codebase-graph pipeline already produces. This table is the contract the sub-PRDs inherit. + +| Visual capability | Existing artifact it consumes | Source | +|---|---|---| +| The node-link graph itself | The canonical snapshot JSON (NetworkX node-link, directed multigraph) | `src/graph/types.ts:23-41`, `src/graph/snapshot.ts:125-165` | +| Locating the snapshot to render | `resolveSnapshot` (follow `latest-commit.txt`, then most-recent mtime; never throws) | `src/dashboard/data.ts:141-209` | +| Node identity, location, kind, language | `GraphNode` (`id`, `label`, `kind`, `source_file`, `source_location`, `language`, `exported`) | `src/graph/types.ts:92-125` | +| Node visual weight (hubs, entrypoints) | Derived `fan_in` / `fan_out` / `is_entrypoint` | `src/graph/node-metadata.ts:18-32` | +| Edge rendering and direction | `GraphEdge` (`source`, `target`, `relation`, `confidence`, `ord`) | `src/graph/types.ts:149-181` | +| Click-to-line navigation | `source_location` format `L<line>` / `L<line>-<endLine>`, 1-indexed | `src/graph/types.ts:99-102` | +| Layer / subsystem grouping | `layerOf` path heuristic | `src/graph/render/layers.ts:9-30` | +| Blast-radius (impact) overlay | Reverse-dependency BFS by depth, with lower-bound caveat | `src/graph/render/impact.ts:47-112` | +| File neighborhood overlay | Cross-file neighbor grouping by relation/direction | `src/graph/render/neighborhood.ts:79-160` | + +--- + +## Cross-cutting requirements + +- **One graph, one source of truth.** The visualizer renders the same snapshot the agent VFS reads, located the same way (`resolveSnapshot`, `src/dashboard/data.ts:141-209`). It maintains no separate graph store and never re-extracts. +- **Never-throw rendering.** Every state (no snapshot, stale snapshot, oversized graph, malformed JSON) has a defined, explained UI, matching the never-throw contracts in `src/dashboard/data.ts:141-209` and `src/graph/vfs-handler.ts:164-216`. +- **Honesty over completeness.** Wherever cross-file resolution is partial (bare/aliased/barrel/dynamic imports, Python cross-file), the visualizer discloses the limit rather than implying certainty (`src/graph/vfs-handler.ts:297-304`, `src/graph/render/impact.ts:110-112`). +- **Local-only read path.** Snapshot reads touch only local disk with zero network calls (`src/graph/vfs-handler.ts:20-22`); the Webview payload and logs never carry tokens or API keys (defers to PRD-002b secrets rules). +- **Lifecycle coherence.** The view reflects graph presence/absence the same way the PRD-002c status bar and PRD-003b settings panel do, and refreshes when a build completes; the three surfaces never disagree about whether a graph exists or how old it is. +- **Editor-native feel.** The graph respects Cursor's theme (light/dark), uses editor tokens, and behaves like a first-party panel, consistent with the PRD-003 Webview presentation requirements. + +--- + +## Open questions + +- [ ] What rendering technology best fits a Cursor Webview at the snapshot sizes Hivemind produces: a 2D canvas/WebGL force layout, a 3D force layout, or both behind a toggle? (Trades visual richness against performance and accessibility.) +- [ ] At what node/edge count does an unfiltered force layout stop being smooth in a Webview, and what is the right default initial view above that threshold (layer-collapsed, top-`fan_in` only, or active-file neighborhood)? +- [ ] Should the visualizer render file-level nodes, symbol-level nodes, or a collapsible hierarchy between them, given the snapshot encodes both via `source_file` and symbol `id`? +- [ ] For editor-to-graph sync, what is the cheapest reliable way to map a cursor position to a node `id` given `source_location` is a line range, multiple symbols can share a line, and the snapshot may be stale relative to live edits? +- [ ] For the impact overlay on unstaged changes, should changed symbols be detected by re-extracting dirty files on the fly, or by mapping `git diff --name-only` files to existing nodes (faster, but blind to brand-new symbols not yet in the snapshot)? +- [ ] How should the view present a snapshot that is stale relative to live edits (mtime newer than the build, as the VFS index warns, `src/graph/vfs-handler.ts:304`): a passive "graph is N commits behind" banner, or an active prompt to rebuild via PRD-003b? +- [ ] Should the graph view be a tab within the single PRD-003 dashboard Webview or a separate dedicated Webview panel, given graph rendering is heavier than the KPI/settings/session panes? + +--- + +## Related + +- [`prd-004a-graph-webview`](./prd-004a-graph-webview.md): the interactive force-directed graph rendering. +- [`prd-004b-editor-sync`](./prd-004b-editor-sync.md): bidirectional editor and graph navigation. +- [`prd-004c-impact-visualizer`](./prd-004c-impact-visualizer.md): change-impact (blast radius) overlay. +- [`../prd-003-cursor-extension-dashboard/prd-003-cursor-extension-dashboard-index.md`](../prd-003-cursor-extension-dashboard/prd-003-cursor-extension-dashboard-index.md): the Stage 3 dashboard this visualizer is embedded in. +- [`../prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md`](../prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md): the Webview shell the graph view shares. +- [`../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md`](../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md): owns graph build/refresh, which this view consumes and re-triggers. +- [`../prd-002-cursor-extension-core/prd-002c-status-bar.md`](../prd-002-cursor-extension-core/prd-002c-status-bar.md): the status bar whose graph presence/absence this view stays coherent with. +- [`../../../knowledge/private/standards/documentation-framework.md`](../../../knowledge/private/standards/documentation-framework.md): documentation standards this PRD conforms to. +- Source grounding: `src/graph/types.ts:23-252` (snapshot/node/edge schema), `src/graph/snapshot.ts:40-252` (snapshot build, hashing, on-disk layout), `src/graph/node-metadata.ts:18-32` (`fan_in`/`fan_out`/`is_entrypoint`), `src/graph/vfs-handler.ts:56-216` (the text VFS the visualizer mirrors visually, snapshot loading, never-throw), `src/graph/render/impact.ts:22-113` (reverse-dependency BFS), `src/graph/render/neighborhood.ts:13-160` (cross-file neighbor grouping), `src/graph/render/layers.ts:9-82` (subsystem grouping), `src/dashboard/data.ts:122-209` (`graphsRoot`, `resolveSnapshot`), `src/dashboard/render.ts` (the external-browser force-directed view this supersedes in-editor), `src/cli/index.ts:462-465` (`hivemind graph build`). diff --git a/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004a-graph-webview.md b/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004a-graph-webview.md new file mode 100644 index 00000000..9ad7b393 --- /dev/null +++ b/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004a-graph-webview.md @@ -0,0 +1,169 @@ +# PRD-004a: Interactive Graph Webview + +> **Status:** Backlog +> **Priority:** P2 +> **Effort:** L (1-3d) +> **Schema changes:** None +> **Parent:** [`prd-004-cursor-graph-visualizer-index`](./prd-004-cursor-graph-visualizer-index.md) + +--- + +## Overview + +This sub-feature is the canvas. It takes the codebase-graph snapshot that already exists on disk and draws it as an interactive, force-directed node-link graph inside the Cursor dashboard Webview. It is the first thing a developer sees when they open the Graph view, and it is the surface every other PRD-004 capability layers on top of: PRD-004b's editor sync highlights nodes here, and PRD-004c's impact overlay colors a subset of these nodes. + +The value is comprehension. The codebase graph is the most information-dense artifact Hivemind produces (`src/graph/types.ts:23-41`), and until now a human could only experience it as text tables (`src/graph/render/`) or an external-browser HTML file (`src/dashboard/render.ts`). This pane turns it into a living picture the developer can pan, zoom, filter, and read at a glance, rendered from the exact same bytes the agent VFS reads, so the human view and the agent context are guaranteed to match. Nothing here re-extracts or re-computes the graph; it reads the snapshot and draws it. + +--- + +## Why this matters + +A node-link diagram answers questions text cannot. Which files cluster together? Which function is a hub everyone depends on? Which exported symbols are entrypoints nobody calls internally? The snapshot already encodes the answers, the pipeline computes `fan_in`, `fan_out`, and `is_entrypoint` for every node after full cross-file resolution (`src/graph/node-metadata.ts:18-32`), but those answers are buried in capped text output today. The neighborhood renderer caps at 25 entries (`src/graph/render/neighborhood.ts:3`), the impact renderer caps at 80 (`src/graph/render/impact.ts:18`), and the find renderer caps at 50 (`src/graph/vfs-handler.ts:407`), because text has to stay bounded for an agent's context window. A visual canvas has no such ceiling: it can show thousands of nodes and let spatial layout, not truncation, manage density. + +The graph also already knows its own shape well enough to render meaningfully: nodes carry a `kind` (function, class, method, interface, and so on, `src/graph/types.ts:127-136`), a `language` (`src/graph/types.ts:138-147`), and an `exported` flag; edges carry a `relation` (`imports`, `calls`, `extends`, `implements`, `method_of`, `src/graph/types.ts:169-179`) and a `confidence` label (`src/graph/types.ts:181`). Every one of these is a visual encoding the developer can use to make sense of the picture. This pane's job is to map that structured data onto color, size, shape, and grouping, faithfully and legibly. + +--- + +## Goals + +- Render the resolved snapshot as an interactive force-directed node-link graph inside the dashboard Webview, loading it via the existing `resolveSnapshot` resolver so the rendered graph and the agent VFS graph are the same snapshot (`src/dashboard/data.ts:141-209`). +- Encode node meaning visually: distinguish node `kind`, surface `exported` vs internal, and size or weight nodes by `fan_in` / `fan_out` so hubs and entrypoints stand out (`src/graph/types.ts:92-125`, `src/graph/node-metadata.ts:18-32`). +- Encode edge meaning visually: distinguish `relation` types and show direction (caller to callee, importer to imported), consistent with the directed-multigraph contract (`src/graph/types.ts:23-41`). +- Let the developer filter and focus the graph: by layer (`layerOf`, `src/graph/render/layers.ts:21-30`), by node kind, by edge relation, and by a text match on node id/label consistent with the VFS `find` semantics (`src/graph/vfs-handler.ts:322-360`). +- Stay performant at the snapshot sizes Hivemind produces by applying level-of-detail or an intelligently filtered initial view above a node/edge threshold, instead of attempting to force-lay-out everything at once. +- Render honest states for every non-happy path: no snapshot, a stale snapshot, an oversized graph, and a malformed snapshot each get a coherent, explained view, never a blank canvas or a crash. + +## Non-Goals + +- **Loading or parsing strategy beyond the existing resolver.** This pane consumes `resolveSnapshot` output (`src/dashboard/data.ts:141-209`); it does not re-derive the snapshot location, re-validate the content hash, or re-extract source. +- **Editor navigation.** Click-to-open and cursor-to-node highlighting are PRD-004b. This pane renders the graph and exposes node identity; it does not own editor interaction. +- **Impact / blast-radius coloring.** Highlighting dependents of unstaged changes is PRD-004c. This pane provides the base rendering and a highlight API that PRD-004c drives. +- **Triggering builds.** A "Build" or "Refresh" action routes to PRD-003b's settings manager (`prd-003b-settings-manager.md`); this pane does not invoke `hivemind graph build` itself. +- **Editing the graph or the code.** Nodes are read-only visual representations; the pane does not rename, delete, or move symbols. +- **Inventing new graph metrics.** Node weighting uses the metadata already on the snapshot (`fan_in`, `fan_out`, `is_entrypoint`); this pane does not compute new centrality measures. + +--- + +## What the pane renders + +The renderer maps snapshot fields directly to visual encodings. This table is the contract between the snapshot shape and the drawn graph. + +| Visual element | Driven by | Source | +|---|---|---| +| A node | One `GraphNode` (`id` is the stable key, `label` is the display name) | `src/graph/types.ts:92-100` | +| Node shape / icon | `kind` (function, class, method, interface, type_alias, enum, const, variable, module) | `src/graph/types.ts:127-136` | +| Node accent (language) | `language` (typescript, javascript, python, go, rust, java, ruby, c, cpp) | `src/graph/types.ts:138-147` | +| Node size / weight | `fan_in` and `fan_out` (incoming/outgoing degree in the resolved graph) | `src/graph/node-metadata.ts:18-32` | +| Entrypoint marker | `is_entrypoint` (`exported && fan_in === 0`) | `src/graph/types.ts:123-124` | +| Public/internal cue | `exported` | `src/graph/types.ts:104-105` | +| An edge | One `GraphEdge` from `source` to `target` | `src/graph/types.ts:149-167` | +| Edge style | `relation` (imports, calls, extends, implements, method_of) | `src/graph/types.ts:169-179` | +| Edge certainty cue | `confidence` (EXTRACTED, INFERRED, AMBIGUOUS) | `src/graph/types.ts:181` | +| Layer grouping / clustering | `layerOf(source_file)` (Tests, Hooks, CLI, Graph, Shell/VFS, Embeddings, Skillify, Config, Utils, Core) | `src/graph/render/layers.ts:9-30` | + +> Accessibility note: per the PRD-003 presentation rules, encodings must not rely on color alone. Node kind, entrypoint status, and edge relation each need a non-color cue (shape, icon, label, or line style). + +--- + +## Filtering and focus + +A whole-repo graph is overwhelming; the pane must let the developer carve it down the way the text endpoints already do, but visually. + +- **By layer.** Toggle subsystems on/off using the same path heuristic the text `layers` view uses (`src/graph/render/layers.ts:9-30`), so "show only the Graph and CLI layers" is one action. The "first match wins" ordering of the rules is preserved so a file lands in exactly one layer. +- **By node kind.** Show or hide functions, classes, methods, interfaces, and so on, mapping to `NodeKind` (`src/graph/types.ts:127-136`). +- **By edge relation.** Show or hide `imports`, `calls`, `extends`, `implements`, `method_of` independently (`src/graph/types.ts:169-179`), so a developer can see "just the import structure" or "just the call graph." +- **By text match.** A search box filters/highlights nodes by substring on `id` and `label`, mirroring the VFS `find` ranking (exact label, prefix, label-contains, id-contains, `src/graph/vfs-handler.ts:611-619`) so the visual search and the agent search agree on what matches. +- **Focus a node.** Selecting a node can collapse the view to its neighborhood, the visual analogue of `neighborhood/<file>` and `show/<node>` (`src/graph/render/neighborhood.ts:13-160`, `src/graph/vfs-handler.ts:549-607`), showing only that node and its direct edges. + +--- + +## Performance and level-of-detail + +The pane must stay smooth at real snapshot sizes (the snapshot records its own `node_count` and `edge_count`, `src/graph/last-build.ts:35-37`, so the size is known before the full file is parsed). + +1. **Read the size cheaply first.** Use the counts from `.last-build.json` (`src/graph/last-build.ts:29-37`) or the `nodeCount`/`edgeCount` the resolver returns (`src/dashboard/data.ts:205-206`) to decide the initial render strategy before laying out anything. +2. **Pick an initial view by size.** Below a threshold, render the full graph. Above it, render a reduced initial view (for example layer-collapsed super-nodes, or the top nodes by `fan_in`) and let the developer expand, rather than attempting a full force layout that freezes the Webview. +3. **Say what was reduced.** Whenever the initial view is filtered for performance, the pane states it ("showing the 500 highest-connectivity symbols; expand a layer to see more") so the developer never mistakes a reduced view for the whole graph. +4. **Incremental layout.** Prefer a layout that can settle progressively and remain interactive (pan/zoom responsive) during settling, rather than blocking until the simulation converges. + +--- + +## The pane's data flow + +```mermaid +flowchart TD + open["Developer opens the Graph view"] --> locate["resolveSnapshot(repoDir)"] + locate --> found{"Snapshot found?"} + found -->|"No"| noGraph["No-graph state:<br/>'Build a graph to visualize it' + route to PRD-003b"] + found -->|"Yes (counts known)"| size{"Within smooth-render size?"} + size -->|"No"| reduced["Reduced initial view<br/>(layer-collapsed / top fan_in) + 'showing N of M' note"] + size -->|"Yes"| full["Full force-directed layout"] + reduced --> draw["Draw nodes (kind/lang/fan-in) + edges (relation/direction)"] + full --> draw + draw --> interact["Pan, zoom, filter by layer/kind/relation/text"] + interact --> expose["Expose selected node id + source_location<br/>(consumed by PRD-004b / PRD-004c)"] +``` + +--- + +## Honest states + +Every failure mode the data layer and VFS already handle gets a visual equivalent here. + +| Condition | Underlying behavior | What the pane shows | +|---|---|---| +| No snapshot for repo | `resolveSnapshot` returns `null` (`src/dashboard/data.ts:181`) | "No graph built yet" empty state with a Build action routing to PRD-003b. | +| Snapshot present but stale vs live edits | VFS index warns mtime newer than build (`src/graph/vfs-handler.ts:304`) | The graph renders with a "may be N behind HEAD; rebuild for accuracy" banner. | +| Malformed / truncated snapshot | Resolver and VFS both reject non-array `nodes`/`links` (`src/dashboard/data.ts:197-201`, `src/graph/vfs-handler.ts:207-210`) | An explained error state ("the snapshot looks corrupt; rebuild it"), not a stack trace. | +| Oversized graph | Counts exceed the smooth-render threshold | A reduced initial view plus an explicit "showing N of M" disclosure. | +| Empty graph (zero nodes) | A snapshot with no extractable symbols | "No symbols found in this snapshot" rather than a blank canvas. | + +--- + +## Presentation requirements + +- **Editor-native and theme-aware.** The canvas respects Cursor's light/dark theme and uses editor color tokens; it reads as a first-party panel, not an embedded webpage (consistent with PRD-003a presentation rules). +- **Legible at a glance.** Hubs (high `fan_in`), entrypoints (`is_entrypoint`), and layers are visually distinguishable without clicking; a legend explains the encodings. +- **No color-only encoding.** Node kind, entrypoint status, and edge relation each carry a shape/icon/label/line-style cue in addition to any color, for accessibility. +- **Responsive interaction.** Pan, zoom, and filter remain responsive during and after layout settling; the pane never appears frozen while computing a layout. +- **Honest reductions.** Any performance-driven filtering of the initial view is disclosed in the UI, never silent. +- **No secret leakage.** The serialized snapshot payload handed to the Webview and any logs contain only graph structure (local-only read path, `src/graph/vfs-handler.ts:20-22`); no tokens or API keys appear. + +--- + +## Acceptance criteria + +| ID | Criterion | +|---|---| +| AC-1 | Given a built snapshot, when the Graph view opens, then a force-directed node-link graph renders from the `resolveSnapshot` output, with the same node and edge counts the resolver reports. | +| AC-2 | Given the rendered graph, when a developer inspects a node, then its `kind`, `language`, `exported` status, and connectivity (`fan_in`/`fan_out`) are visually encoded with non-color cues. | +| AC-3 | Given the rendered graph, when edges are drawn, then each edge shows its `relation` and direction (source to target) consistent with the directed-multigraph contract. | +| AC-4 | Given the filter controls, when the developer filters by layer, node kind, or edge relation, then the visible graph updates to match, using the same layer heuristic and kinds the snapshot/text views use. | +| AC-5 | Given the search box, when the developer types a substring, then matching nodes are highlighted/filtered using the same ranking as the VFS `find` endpoint. | +| AC-6 | Given a snapshot whose size exceeds the smooth-render threshold, when the view opens, then it renders a reduced initial view and discloses "showing N of M," and the editor does not freeze. | +| AC-7 | Given no snapshot exists, when the view opens, then it shows a "no graph yet" empty state with a Build action routing to PRD-003b, not a blank canvas. | +| AC-8 | Given a malformed snapshot, when the view loads it, then it shows an explained error state and offers a rebuild, consistent with the data layer's never-throw contract. | +| AC-9 | Given a selected node, when another PRD-004 capability requests its identity, then the pane exposes the node `id` and `source_location` for editor sync (PRD-004b) and impact highlighting (PRD-004c). | +| AC-10 | Given the Webview payload or logs are inspected, when their contents are examined, then only graph structure appears, with no token or API key value. | + +--- + +## Open questions + +- [ ] Which force-layout library performs acceptably inside a Cursor Webview at Hivemind's snapshot sizes, and should 3D be offered as a toggle or deferred entirely? +- [ ] Should the default node granularity be symbol-level (every `GraphNode`), file-level (collapsed by `source_file`), or a collapsible hierarchy, given the snapshot supports both? +- [ ] What exact node/edge count is the smooth-render threshold in a Webview, and is it fixed or adaptive to the machine? +- [ ] When reducing an oversized graph, is "top-N by `fan_in`" or "layer-collapsed super-nodes" the more useful default initial view? +- [ ] Should edge `confidence` (EXTRACTED vs INFERRED vs AMBIGUOUS) be visually distinguished now, given Phase 1 edges are almost entirely EXTRACTED (`src/graph/types.ts:158-160`)? +- [ ] Should the search box reuse the VFS fuzzy fallback (`src/graph/vfs-handler.ts:368-378`) when there is no exact substring match, for parity with agent search? + +--- + +## Related + +- [`prd-004-cursor-graph-visualizer-index`](./prd-004-cursor-graph-visualizer-index.md): parent module. +- [`prd-004b-editor-sync`](./prd-004b-editor-sync.md): consumes the node identity and `source_location` this pane exposes. +- [`prd-004c-impact-visualizer`](./prd-004c-impact-visualizer.md): drives the highlight API this pane provides. +- [`../prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md`](../prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md): the Webview shell and presentation conventions this pane inherits. +- [`../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md`](../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md): owns graph build/refresh the empty/stale states route to. +- Source grounding: `src/graph/types.ts:23-181` (snapshot, node, and edge schema this pane renders), `src/graph/node-metadata.ts:18-32` (`fan_in`/`fan_out`/`is_entrypoint` weighting), `src/graph/render/layers.ts:9-30` (layer heuristic for clustering/filtering), `src/graph/vfs-handler.ts:322-360,611-619` (find/match ranking the search box mirrors), `src/dashboard/data.ts:141-209` (`resolveSnapshot` the pane loads from), `src/graph/last-build.ts:29-37` (cheap `node_count`/`edge_count` for the size decision), `src/dashboard/render.ts` (the external-browser force-directed view this pane brings in-editor). diff --git a/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004b-editor-sync.md b/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004b-editor-sync.md new file mode 100644 index 00000000..0df74688 --- /dev/null +++ b/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004b-editor-sync.md @@ -0,0 +1,146 @@ +# PRD-004b: Editor Sync & Navigation + +> **Status:** Backlog +> **Priority:** P2 +> **Effort:** L (1-3d) +> **Schema changes:** None +> **Parent:** [`prd-004-cursor-graph-visualizer-index`](./prd-004-cursor-graph-visualizer-index.md) + +--- + +## Overview + +This sub-feature fuses the graph and the editor into one surface. The graph rendered by PRD-004a is informative, but a static picture of a codebase is only half the value. The other half is navigation: a developer should be able to click any node and land on the exact line of code it represents, and, going the other way, see where they currently are in the code reflected as a highlighted node in the graph. This pane makes the map and the territory move together. + +The value is the elimination of the context switch. Today, understanding "where is this function and what is around it" means reading a text node-detail dump (`src/graph/vfs-handler.ts:549-607`) that prints `source_file:source_location`, then manually opening that file and scrolling to that line. Every node the snapshot stores already carries its precise location, encoded as `L<line>` or `L<line>-<endLine>` and guaranteed 1-indexed (`src/graph/types.ts:99-102`). This pane turns that stored coordinate into a single click that opens the file and positions the cursor, and it closes the loop by mapping the editor's live cursor back onto the graph. The graph stops being a thing you look at and becomes a thing you navigate with. + +--- + +## Why this matters + +The snapshot was built to be navigable, but nothing has ever let a human navigate it. Three facts make this sub-feature both valuable and tractable: + +1. **Every node knows exactly where it lives.** `GraphNode.source_file` is a repo-root-relative path with forward slashes and no leading slash (`src/graph/types.ts:99-100`), and `GraphNode.source_location` is a 1-indexed `L<line>` or `L<line>-<endLine>` range (`src/graph/types.ts:101-102`). That is precisely the information an editor needs to open a file and place a cursor; the text renderers already parse it (`parseLocation`, `src/graph/render/neighborhood.ts:162-165`). +2. **The node id is a stable, reversible key.** Node ids follow the format `<source_file>:<symbol_name>:<kind>` (`src/graph/types.ts:92-93`), so a node carries its file and kind in its identity. That makes both directions feasible: graph-to-editor (read the location off the node) and editor-to-graph (find nodes whose `source_file` matches the active file, then narrow by line). +3. **The cross-reference is one-to-many and sometimes stale.** Multiple symbols can occupy or overlap a line, and the snapshot can lag live edits (the VFS index warns when a file's mtime is newer than the build, `src/graph/vfs-handler.ts:304`). So editor-to-graph mapping must resolve ambiguity gracefully and degrade honestly when the snapshot no longer matches the file, rather than jumping to the wrong node or silently failing. + +Getting this right is what makes the visualizer feel like part of Cursor instead of a picture pinned next to it. + +--- + +## Goals + +- Clicking a node in the graph opens its `source_file` in Cursor and moves the cursor to the start line parsed from `source_location`, selecting or revealing the declaration (`src/graph/types.ts:99-102`). +- Moving the cursor in an open editor highlights the corresponding node in the graph, resolved by matching the active file to node `source_file` and the cursor line to the node's `source_location` range. +- Resolve the inherent ambiguity (several symbols on or near one line) deterministically and visibly, preferring the most specific enclosing symbol and disclosing when multiple candidates match. +- Degrade honestly when the snapshot is stale relative to the file: a node whose location no longer matches the live file opens the file at the best-known line and signals that the graph may be behind, consistent with the VFS staleness caveat (`src/graph/vfs-handler.ts:304`). +- Keep the sync responsive and non-intrusive: editor-to-graph highlighting tracks cursor movement within one sync interval without stealing focus or fighting the developer's scrolling. + +## Non-Goals + +- **Rendering the graph.** Drawing nodes/edges and exposing node identity is PRD-004a. This pane consumes the selected-node id and `source_location` PRD-004a exposes and drives its highlight API. +- **Impact / blast-radius highlighting.** Coloring dependents of unstaged changes is PRD-004c. This pane owns single-symbol navigation and active-node highlighting, not multi-node impact sets. +- **Re-extracting or rebuilding to fix staleness.** When the snapshot lags the file, this pane discloses the gap and routes a rebuild to PRD-003b; it does not re-run extraction itself. +- **Editing code from the graph.** Navigation positions the cursor; it does not modify the symbol it lands on. +- **Cross-file "go to definition" semantics.** This pane navigates to the node's own declaration line. Following an edge to a callee's definition is a graph navigation (selecting the target node), not a language-server jump. +- **Multi-editor / split-view orchestration.** Beyond opening the file and placing the cursor in the active editor group, advanced split or peek layouts are out of scope. + +--- + +## Graph to editor: click a node, land on the line + +The forward direction is the simpler one because the node already carries everything needed. + +1. **Read the coordinates off the node.** `source_file` gives the repo-root-relative path; `source_location` gives the 1-indexed start line (and optional end line). The same parse the text renderer uses applies (`parseLocation` extracts the leading `L<n>`, `src/graph/render/neighborhood.ts:162-165`). +2. **Resolve to a workspace path.** Join `source_file` to the repo root to get the on-disk file. Because `source_file` is normalized (forward slashes, no leading slash, `src/graph/types.ts:99-100`), this is a direct join. +3. **Open and position.** Open the document in Cursor and move the cursor to the start line, revealing the range when an end line is present, so the whole declaration is visible. +4. **Handle a missing or moved target.** If the file no longer exists, or the line is beyond the file's current length (an edit shrank it), open the file at the nearest valid position and signal that the graph may be stale, rather than throwing. + +```mermaid +sequenceDiagram + participant Dev as Developer + participant Graph as Graph canvas (PRD-004a) + participant Sync as Editor-sync controller + participant Cursor as Cursor editor + + Dev->>Graph: Click node + Graph->>Sync: node id + source_file + source_location + Sync->>Sync: parse start line (L<n>); join source_file to repo root + Sync->>Cursor: open document + reveal range + alt File + line valid + Cursor-->>Dev: cursor on the declaration line + else File missing or line out of range + Cursor-->>Dev: best-effort open + "graph may be stale" hint + end +``` + +--- + +## Editor to graph: move the cursor, highlight the node + +The reverse direction is the harder one because the mapping is one-to-many and time-sensitive. + +- **Match the file first.** From the active editor, compute the repo-root-relative path and collect candidate nodes whose `source_file` equals it. This is a cheap filter over the snapshot's nodes (the same `source_file` field the layer and neighborhood views group on, `src/graph/render/neighborhood.ts:49-56`). +- **Narrow by line.** Among the file's nodes, choose the one whose `source_location` range contains (or most tightly encloses) the cursor line. When `source_location` is a single line (`L<line>`), match by proximity; when it is a range (`L<line>-<endLine>`), prefer the smallest enclosing range so a method inside a class wins over the class. +- **Resolve ties deterministically.** If multiple nodes still match (overlapping ranges, same line), pick by a stable rule (smallest range, then node `id` order, mirroring the deterministic ordering the snapshot is built with, `src/graph/snapshot.ts:98-110`) and, when genuinely ambiguous, allow the graph to indicate more than one candidate rather than guessing silently. +- **Highlight, do not hijack.** Highlighting the active node updates the graph's selection/emphasis (driving PRD-004a's highlight API). It must not steal editor focus, recenter the canvas abruptly on every keystroke, or fight the developer's manual panning; debouncing on cursor movement keeps it calm. +- **Handle "no matching node."** A cursor on a line with no symbol (a comment, a blank line, brand-new code not yet in the snapshot) clears the highlight or leaves the last selection, with no error. + +--- + +## The staleness reality + +Editor sync is the place where snapshot staleness becomes most visible, because the developer is comparing a stored coordinate against a live buffer. + +- **The snapshot can lag the file.** Builds are point-in-time; the VFS itself warns that a file whose mtime is newer than the build may have moved on (`src/graph/vfs-handler.ts:304`). A function the snapshot says is at `L42` may now be at `L58`. +- **Forward navigation stays useful anyway.** Opening the file at the stored line still lands the developer in the right neighborhood; combined with a "graph may be N behind" cue (shared with PRD-004a's stale banner), it is honest and still helpful. +- **Reverse navigation tolerates drift.** Line-range matching with smallest-enclosing preference is resilient to small line shifts; when drift is large enough that no node matches, the pane clears the highlight rather than snapping to a wrong node. +- **Rebuild is one route away.** Where staleness is material, the pane surfaces the same Build/Refresh affordance owned by PRD-003b (`prd-003b-settings-manager.md`) so the developer can resync the graph to HEAD. + +--- + +## Presentation requirements + +- **Single-click forward navigation.** One click on a node opens the file and positions the cursor; no intermediate dialog for the unambiguous case. +- **Calm reverse highlighting.** Active-node highlighting tracks the cursor within one sync interval, debounced, and never steals editor focus or yanks the canvas on every keystroke. +- **Visible ambiguity, never silent guessing.** When several nodes match a cursor line, the pane indicates the candidate set or its deterministic pick rather than jumping arbitrarily. +- **Honest staleness.** When a node's stored location no longer matches the live file, navigation still works best-effort and the "graph may be stale" cue is shown. +- **Accessible.** Selection and highlight states are conveyed by more than color (outline, label, or focus ring), consistent with PRD-004a's accessibility rule. +- **No secret leakage.** The messages passed between the Webview and the extension carry only node ids, file paths, and line numbers, never tokens or API keys. + +--- + +## Acceptance criteria + +| ID | Criterion | +|---|---| +| AC-1 | Given a rendered graph, when the developer clicks a node, then Cursor opens the node's `source_file` and moves the cursor to the start line parsed from `source_location`. | +| AC-2 | Given a node whose `source_location` is a range (`L<line>-<endLine>`), when it is clicked, then the editor reveals the full range so the declaration is visible, not just the first line. | +| AC-3 | Given an open editor on a file represented in the graph, when the developer moves the cursor onto a symbol, then the corresponding node is highlighted in the graph within one sync interval. | +| AC-4 | Given a cursor line that several nodes could match, when reverse sync runs, then the pane selects the smallest enclosing symbol deterministically and indicates when multiple candidates exist rather than guessing silently. | +| AC-5 | Given a cursor on a line with no represented symbol, when reverse sync runs, then the highlight is cleared or left unchanged with no error. | +| AC-6 | Given a snapshot that is stale relative to the live file, when the developer clicks a node whose line has shifted, then the file opens at the best-known position and a "graph may be stale" cue is shown. | +| AC-7 | Given a node whose file no longer exists, when it is clicked, then the pane reports the missing file gracefully instead of throwing. | +| AC-8 | Given reverse highlighting is active, when the developer types or scrolls, then highlighting is debounced and never steals editor focus or recenters the canvas on every keystroke. | +| AC-9 | Given the messages exchanged between the Webview and extension are inspected, when their contents are examined, then only node ids, file paths, and line numbers appear, with no token or API key. | + +--- + +## Open questions + +- [ ] What is the cheapest reliable way to compute the active file's repo-root-relative path so it matches the snapshot's `source_file` normalization across worktrees and symlinks? +- [ ] When `source_location` is a single line (`L<line>`) and several symbols share it, what is the best tie-break: declaration order, node `id` order, or `kind` priority (method over class)? +- [ ] Should reverse sync match against the live buffer's current line directly, or attempt a small fuzzy line-offset search to absorb minor staleness before clearing the highlight? +- [ ] Should clicking a node reveal the range via selection, a non-destructive highlight decoration, or a peek, to avoid surprising the developer with a selection they did not make? +- [ ] How is the repo root resolved for the path join, and does it reuse the same repo-identity derivation the snapshot loader uses (`deriveProjectKey`, `src/graph/vfs-handler.ts:171`)? +- [ ] Should editor-to-graph sync be on by default or opt-in, given some developers may find live highlighting distracting during deep editing? + +--- + +## Related + +- [`prd-004-cursor-graph-visualizer-index`](./prd-004-cursor-graph-visualizer-index.md): parent module. +- [`prd-004a-graph-webview`](./prd-004a-graph-webview.md): renders the graph and exposes the node identity and highlight API this pane drives. +- [`prd-004c-impact-visualizer`](./prd-004c-impact-visualizer.md): shares the same node-to-file mapping, applied to a set of changed symbols rather than a single click. +- [`../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md`](../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md): owns the rebuild the stale-graph cue routes to. +- Source grounding: `src/graph/types.ts:92-125` (node `id` format, `source_file`, `source_location`), `src/graph/render/neighborhood.ts:49-56,162-165` (file-to-node matching and `parseLocation`), `src/graph/vfs-handler.ts:304,549-607` (the staleness caveat and the text node-detail this pane supersedes with navigation), `src/graph/snapshot.ts:98-110` (deterministic node ordering for tie-breaks), `src/graph/vfs-handler.ts:171` (`deriveProjectKey` repo-identity derivation). diff --git a/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004c-impact-visualizer.md b/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004c-impact-visualizer.md new file mode 100644 index 00000000..5805055b --- /dev/null +++ b/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004c-impact-visualizer.md @@ -0,0 +1,142 @@ +# PRD-004c: Change Impact Visualizer + +> **Status:** Backlog +> **Priority:** P2 +> **Effort:** L (1-3d) +> **Schema changes:** None +> **Parent:** [`prd-004-cursor-graph-visualizer-index`](./prd-004-cursor-graph-visualizer-index.md) + +--- + +## Overview + +This sub-feature answers the question every developer asks before touching shared code: "if I change this, what else could break?" The codebase graph already contains the answer, the transitive set of dependents of any symbol, and the reverse-dependency traversal that computes it is already implemented and tested as the text `impact/` endpoint (`src/graph/render/impact.ts:22-113`). What has never existed is a way to ask that question about the code the developer is *actually editing right now*, and to see the answer as a highlight over the graph rather than a text query they have to phrase by hand. + +This pane closes that gap. It reads the developer's unstaged changes, maps the changed files (and the symbols within them) onto graph nodes, runs the same reverse-dependency BFS the text endpoint uses to find the blast radius, and highlights that affected neighborhood over the PRD-004a graph. The value is foresight: the ripple effects of an edit become visible before the edit is finished, so a refactor of a high-`fan_in` utility starts with a clear picture of who depends on it instead of a hopeful guess. And because the underlying traversal is honest about its limits (cross-file resolution is partial, so the result is a lower bound, `src/graph/render/impact.ts:80-82,110-112`), this pane is honest too: it shows what is provably affected and says plainly that more may be. + +--- + +## Why this matters + +The blast-radius computation is mature; what is missing is the trigger and the visualization. + +1. **The traversal already exists and is honest.** `renderImpact` resolves a symbol, builds a reverse adjacency map over the fully-resolved edges, and runs a depth-bounded BFS to collect every transitive dependent, grouped by distance, with explicit lower-bound caveats (`src/graph/render/impact.ts:36-112`). It is deterministic, AST-only, and already capped for safety (`IMPACT_CAP = 80`, `MAX_DEPTH = 25`, `src/graph/render/impact.ts:18-20`). This pane reuses that logic rather than reinventing dependency analysis. +2. **The change set is knowable.** Hivemind already reasons about unstaged changes elsewhere: the SessionEnd auto-build hook gates rebuilds on `git diff --name-only` over source globs (`src/graph/last-build.ts:11-12`). The same diff identifies which files the developer is currently editing, and the snapshot's `source_file` field maps those files onto nodes (`src/graph/types.ts:99-100`). +3. **Today the connection does not exist.** A developer can run `cat memory/graph/impact/<symbol>` for one symbol they name explicitly (`src/graph/vfs-handler.ts:111-118`), but nothing watches their working tree and nothing draws the result. The information is one manual text query away, phrased per symbol, with no visual blast radius over the code they are touching. This pane makes it automatic and visual. + +The result turns the graph from a description of how the code *is* into a forecast of what an in-progress change *will reach*. + +--- + +## Goals + +- Detect the developer's unstaged changes and map the changed files to graph nodes using the snapshot's `source_file` field, the same way the auto-build hook identifies dirty source (`src/graph/last-build.ts:11-12`, `src/graph/types.ts:99-100`). +- Compute the blast radius of those changed symbols by reusing the reverse-dependency BFS that powers the text `impact/` endpoint, so the visual result and the agent's text result agree (`src/graph/render/impact.ts:47-69`). +- Highlight the affected neighborhood over the PRD-004a graph: the changed nodes themselves, and their transitive dependents, distinguishable by BFS depth (distance from the change) as the text endpoint groups them (`src/graph/render/impact.ts:88-108`). +- Disclose the honesty caveat prominently: only resolved edges are traversed, so the highlighted set is a lower bound on impact, never a proof of safety (`src/graph/render/impact.ts:80-82,110-112`). +- Keep the overlay live and bounded: it updates as the working tree changes, and it respects the same safety caps (`IMPACT_CAP`, `MAX_DEPTH`) so a pathological graph cannot run the highlight away (`src/graph/render/impact.ts:18-20`). + +## Non-Goals + +- **Computing dependents from scratch.** The reverse-BFS traversal is owned by `src/graph/render/impact.ts`; this pane drives it and visualizes its output. It does not implement a new dependency analyzer. +- **Rebuilding the graph against dirty files.** This pane maps unstaged changes onto the existing snapshot. Re-extracting dirty files to catch brand-new symbols is an open question, not a committed goal; rebuilds route to PRD-003b. +- **Rendering the base graph or single-node navigation.** Drawing nodes/edges is PRD-004a; click-to-line and cursor-to-node are PRD-004b. This pane only adds an impact highlight layer over them. +- **Staging, committing, or any git mutation.** This pane reads the working-tree diff; it never stages, commits, or alters git state. +- **Guaranteeing completeness.** Because cross-file resolution is partial, the overlay is explicitly a lower bound. Promising "these are all the affected files" is a non-goal and would be dishonest. +- **Test-impact or runtime-coverage analysis.** The overlay reflects static graph dependents, not which tests exercise the code or what runs at execution time. + +--- + +## From unstaged changes to a highlighted blast radius + +The pipeline has three stages, each grounded in an existing mechanism. + +| Stage | What happens | Reuses | +|---|---|---| +| **1. Detect the change set** | List unstaged/modified source files in the working tree. | The `git diff --name-only` over source globs the auto-build gate already uses (`src/graph/last-build.ts:11-12`). | +| **2. Map files to nodes** | For each changed file, collect the snapshot nodes whose `source_file` matches it; these are the "changed symbols" (origin set). | `GraphNode.source_file` normalization (`src/graph/types.ts:99-100`); the same file-to-node filter PRD-004b and `renderNeighborhood` use (`src/graph/render/neighborhood.ts:49-56`). | +| **3. Traverse and highlight** | Run the reverse-dependency BFS from each origin node, collect transitive dependents grouped by depth, and highlight origin + dependents over the graph. | `renderImpact`'s reverse adjacency + depth-bounded BFS (`src/graph/render/impact.ts:36-69`), within `IMPACT_CAP`/`MAX_DEPTH` (`src/graph/render/impact.ts:18-20`). | + +```mermaid +flowchart TD + edits["Developer has unstaged changes"] --> diff["List changed source files<br/>(git diff --name-only, source globs)"] + diff --> map["Map changed files to nodes by source_file"] + map --> origin{"Any changed symbols in the graph?"} + origin -->|"No"| none["Honest note: 'changed files have no graph nodes<br/>(new file, or rebuild needed)'"] + origin -->|"Yes"| bfs["Reverse-dependency BFS from origin set<br/>(reuse renderImpact traversal)"] + bfs --> group["Group dependents by depth (distance from change)"] + group --> highlight["Highlight origin + dependents over the graph"] + highlight --> caveat["Disclose: resolved edges only, lower bound, not proof of safety"] +``` + +--- + +## Reusing the impact traversal faithfully + +The whole point is parity between what the developer sees and what the agent reads. The traversal must behave identically. + +- **Reverse adjacency over resolved edges only.** `renderImpact` keeps only edges whose `source` is a real node and walks them in reverse to find dependents (`src/graph/render/impact.ts:36-45`). The overlay uses the same edge set, so a dependent in the highlight is always a real graph node, never an unresolved import placeholder. +- **Depth grouping is the visual encoding.** The text endpoint groups dependents by BFS depth (`depth 1`, `depth 2`, and so on, `src/graph/render/impact.ts:88-98`). The overlay turns depth into visual distance/intensity, so "directly affected" reads differently from "three hops away." +- **The "via" relation is available.** The traversal records which relation and source first reached each dependent (`viaOf`, `src/graph/render/impact.ts:62-63,101-102`), so the overlay can explain why a node is in the blast radius (reached via a `calls` edge from X), not just that it is. +- **Multi-origin union.** Unstaged changes usually touch several symbols across several files. The overlay runs the traversal from every origin node and unions the results, so the blast radius reflects the whole change set, not one symbol at a time. +- **Caps are preserved.** The same `IMPACT_CAP` and `MAX_DEPTH` bounds apply (`src/graph/render/impact.ts:18-20`); when the true dependent set exceeds the cap, the overlay highlights up to the cap and states the true total, exactly as the text endpoint reports "... and N more" (`src/graph/render/impact.ts:108`). + +--- + +## The honesty caveat, made visible + +This is the most important non-functional requirement of the pane, because an over-confident blast radius is worse than none. + +- **Resolved edges only.** Cross-file relationships via bare (npm), aliased, barrel, or dynamic imports are not in the graph, and Python cross-file resolution is a follow-up (`src/graph/vfs-handler.ts:297-304`). The traversal therefore yields a lower bound (`src/graph/render/impact.ts:6-12,80-82`). +- **State it where the developer acts.** The overlay must carry the caveat visibly ("resolved dependents only; real impact may be larger"), echoing the text endpoint's own closing note (`src/graph/render/impact.ts:110-112`), so a small highlight is never misread as "safe to change freely." +- **An empty blast radius is not proof of dead code.** When an origin symbol has no resolved dependents, the overlay says so honestly ("no resolved dependents; not proof it is unused"), matching the text endpoint's zero-dependent message (`src/graph/render/impact.ts:79-82`) and the node-detail caveat (`src/graph/vfs-handler.ts:586-588`). +- **Stale snapshots widen the gap.** If the snapshot lags the working tree (the staleness condition PRD-004a and PRD-004b already surface, `src/graph/vfs-handler.ts:304`), the overlay notes that the impact is computed against an older graph and offers the PRD-003b rebuild. + +--- + +## Presentation requirements + +- **Origin and dependents are distinct.** The changed symbols and their dependents are visually separable, and dependents are gradated by depth (distance from the change). +- **The caveat is always present.** No blast-radius view renders without its lower-bound disclosure; the highlight never implies completeness. +- **Bounded and calm.** The overlay respects `IMPACT_CAP`/`MAX_DEPTH`, reports true totals when capped, and updates on working-tree changes without thrashing (debounced, like PRD-004b's reverse sync). +- **Explains why a node is highlighted.** On inspecting a highlighted dependent, the developer can see the relation/path by which the change reaches it (from `viaOf`). +- **Honest empty states.** "No unstaged changes," "changed files have no graph nodes yet," and "no resolved dependents" are each specific, calm messages, never a blank highlight or a crash. +- **No secret leakage.** The diff handling and Webview messages carry only file paths, node ids, relations, and depths, never tokens, API keys, or file contents beyond what is needed to identify nodes. + +--- + +## Acceptance criteria + +| ID | Criterion | +|---|---| +| AC-1 | Given the developer has unstaged changes, when the impact overlay is enabled, then the changed source files are detected via the same diff mechanism the auto-build gate uses and mapped to graph nodes by `source_file`. | +| AC-2 | Given changed symbols mapped to origin nodes, when the overlay computes impact, then it highlights the transitive dependents found by the same reverse-dependency BFS as the text `impact/` endpoint. | +| AC-3 | Given highlighted dependents, when they are rendered, then they are gradated by BFS depth so directly affected symbols are distinguishable from distant ones. | +| AC-4 | Given the overlay is shown, when any blast radius is highlighted, then a lower-bound caveat is visible stating that only resolved edges are traversed and real impact may be larger. | +| AC-5 | Given an origin symbol with no resolved dependents, when the overlay runs, then it states "no resolved dependents (not proof it is unused)" rather than implying the symbol is safe to delete. | +| AC-6 | Given a dependent set larger than `IMPACT_CAP`, when the overlay highlights, then it caps the highlight, reports the true total, and does not exceed `MAX_DEPTH`. | +| AC-7 | Given changed files that contain no nodes in the current snapshot (new file or stale graph), when the overlay runs, then it shows an honest note and offers a rebuild via PRD-003b, not a blank or erroneous highlight. | +| AC-8 | Given the developer makes further edits, when the working tree changes, then the overlay updates within a debounced interval without thrashing the graph. | +| AC-9 | Given a highlighted dependent is inspected, when its provenance is shown, then the relation and source by which the change reaches it are available (from the traversal's recorded path). | +| AC-10 | Given the diff handling and Webview messages are inspected, when their contents are examined, then only file paths, node ids, relations, and depths appear, with no token, API key, or unnecessary file content. | + +--- + +## Open questions + +- [ ] Should changed symbols be detected by mapping `git diff --name-only` files to existing nodes (fast, but blind to brand-new symbols not yet in the snapshot) or by re-extracting dirty files on the fly to catch new declarations? +- [ ] At the file granularity, should the origin set be every node in a changed file, or only the nodes overlapping the changed line ranges from the diff hunks (more precise, but requires hunk-line parsing)? +- [ ] How should the overlay treat a changed file that is entirely new (no prior nodes): show its declared symbols as origins with empty dependents, or prompt a rebuild first? +- [ ] Should the overlay also surface forward dependencies (what the changed symbol calls/imports) in addition to reverse dependents, or stay strictly blast-radius? +- [ ] What is the right default depth limit for the visual highlight (the text endpoint allows up to `MAX_DEPTH = 25`), given a deep highlight may overwhelm the canvas? +- [ ] Should the overlay auto-enable whenever unstaged changes exist, or stay an explicit toggle to avoid surprising developers mid-edit? + +--- + +## Related + +- [`prd-004-cursor-graph-visualizer-index`](./prd-004-cursor-graph-visualizer-index.md): parent module. +- [`prd-004a-graph-webview`](./prd-004a-graph-webview.md): renders the base graph and provides the highlight API this overlay drives. +- [`prd-004b-editor-sync`](./prd-004b-editor-sync.md): shares the file-to-node mapping, applied here to a set of changed files rather than a single click. +- [`../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md`](../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md): owns the rebuild the stale/new-file states route to. +- Source grounding: `src/graph/render/impact.ts:18-113` (reverse-dependency BFS, depth grouping, `viaOf` provenance, `IMPACT_CAP`/`MAX_DEPTH` caps, lower-bound caveats), `src/graph/last-build.ts:11-12` (the `git diff --name-only` source-change detection this overlay reuses), `src/graph/types.ts:99-100,149-179` (`source_file` for file-to-node mapping, edge `relation` for provenance), `src/graph/render/neighborhood.ts:49-56` (file-to-node filtering pattern), `src/graph/vfs-handler.ts:111-118,297-304,586-588` (the text `impact/` endpoint and the partial-resolution honesty caveats this overlay mirrors). 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 -- <globs>`; `: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 <total>"` 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/completed/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 new file mode 100644 index 00000000..976c6a92 --- /dev/null +++ b/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005-cursor-skillify-bridge-index.md @@ -0,0 +1,181 @@ +# PRD-005: Cursor-Native Skillify & Rules Bridge + +> **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). PRD-003 made it visible and controllable (live KPI cards, a graphical settings panel, an in-editor session viewer). PRD-004 made the codebase graph explorable (a force-directed map fused with the editor). Each stage took something Hivemind already did in the background and gave it a first-class, in-editor home. PRD-005 does the same for the two assets that make a team feel like one brain instead of many: its **shared skills** and its **shared rules**. + +Hivemind already mines reusable skills from real sessions and stores them in the org `skills` table, and every agent's SessionStart hook auto-pulls them so a skill mined by a teammate at 10:01 is available to anyone who opens a session at 10:02 (`src/skillify/auto-pull.ts:1-26`). It already keeps a team-wide rules ledger that is injected into every session's context (`src/rules/read.ts:43-84`). Both engines are rich, tested, and live. But inside Cursor they are effectively invisible and, in the case of skills, partly broken: the pull machinery fans pulled skills out to Codex, Hermes, and pi, and then deliberately skips Cursor with the comment "Cursor has no native skill discovery (only hooks/rules), so it is not a candidate" (`src/skillify/agent-roots.ts:27-28`). That assumption is now wrong. Cursor's active agent discovers skills in `.cursor/skills/` (per project) and `~/.cursor/skills-cursor/` (globally), not in the `.claude/skills/` path Hivemind writes to. So a developer working in Cursor can have a perfectly healthy Hivemind, a full org `skills` table, and a successful auto-pull, and still watch their Cursor agent ignore every team skill, because the bytes landed in a directory Cursor never reads. + +PRD-005 delivers the **Cursor-Native Skillify & Rules Bridge**: the stage that closes the path gap and turns both engines into visible, one-click surfaces inside the PRD-003 dashboard. It does three things. It **bridges the path gap**: pulled skills are automatically synced into Cursor's active skill directories so Cursor's agent discovers and uses them the moment they arrive, reusing the existing pull and manifest machinery rather than forking it. It **surfaces the rules ledger**: the active team-wide rules a developer is already governed by become a visible, editable list in the dashboard, with add, edit, and complete as graphical actions over the existing `hivemind rules` engine. And it **makes promotion a single click**: the skills a developer has mined locally are shown in the dashboard, and promoting one so the whole team can pull it stops being a memorized CLI incantation and becomes a button. + +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 has onboarded (PRD-002), uses the dashboard (PRD-003), and explores the graph (PRD-004). They work in Cursor all day. The shared-brain promise around skills and rules still fails them in three distinct, compounding ways: + +1. **Team skills never reach their Cursor agent.** Auto-pull runs on every session and reports success; the org `skills` table is full; the canonical SKILL.md files land under `~/.claude/skills/<name>--<author>/` and are even symlinked into Codex, Hermes, and pi roots (`src/skillify/pull.ts:579-585`, `src/skillify/agent-roots.ts:48-67`). But the fan-out explicitly excludes Cursor, and Cursor reads `.cursor/skills/` and `~/.cursor/skills-cursor/`, never `.claude/skills/`. The result is the most frustrating kind of failure: everything reports green, and nothing works. The developer's Cursor agent silently lacks every skill the team has mined. +2. **The rules they are governed by are invisible.** A team-wide rule ledger exists and is injected into each session's context up to a cap of ten (`src/rules/read.ts:36-38,83`), shaping how every agent behaves. But the developer cannot see that list without dropping to a terminal and running `hivemind rules list` (`src/commands/rules.ts:188-205`), and they cannot add, edit, or complete a rule without memorizing `hivemind rules add "..."`, `edit <uuid> "..."`, or `done <uuid>` (`src/commands/rules.ts:37-45`). The thing steering their agent is governed entirely from outside the editor. +3. **Promoting a hard-won local skill is a chore.** When a developer mines a useful skill, it defaults to `me` scope and the project install location (`src/skillify/scope-config.ts:44`), so it lives only on their machine and never reaches the team. Sharing it means knowing it exists (the only signal today is a count-only SessionStart banner, `src/skillify/local-mined-banner.ts:32-40`), then remembering the right combination of `hivemind skillify promote <name>`, `scope team`, and `install global` (`src/cli/skillify-spec.ts:53-56`). Most good local skills die on the machine that mined them because the path to sharing is invisible and manual. + +Every one of these is a place where Hivemind has already done the hard work, mining, storing, auto-pulling, versioning, and stops one directory or one CLI command short of the developer actually benefiting inside Cursor. PRD-005 closes that final gap for skills and rules the same way PRD-004 closed it for the graph. + +--- + +## Value & success themes + +| Theme | What "good" feels like for the developer | +|---|---| +| **Team skills just work in Cursor** | A skill a teammate mined appears in the developer's Cursor agent automatically, with no manual copy, no symlink command, and no knowledge that `.claude/skills/` and `.cursor/skills/` are even different paths. | +| **No silent path gap** | When skills cannot be synced into Cursor's directories (a real file blocks a symlink, a read-only filesystem, a permission denial), the developer is told, in-editor, exactly which skills are not reaching their agent and why, never left with a false green. | +| **The rules steering me are visible** | The team rules that shape every session are a list the developer can read at a glance, and can add to, edit, or close, without leaving Cursor or learning a single subcommand. | +| **Sharing a skill is one click** | A locally mined skill the developer wants the team to have is promoted from a button, and the dashboard confirms it will reach teammates on their next auto-pull. | +| **One engine, one source of truth** | The bridge reads and writes the same org `skills` table, the same `hivemind_rules` ledger, the same pull manifest, and the same canonical config the CLI uses, so the editor, the terminal, and every other agent never disagree about which skills and rules exist. | + +--- + +## Goals + +- A developer's pulled team skills are automatically synced into Cursor's active skill directories (`~/.cursor/skills-cursor/` for global pulls, `<project>/.cursor/skills/` for project pulls) so Cursor's agent discovers and uses them without any manual action, reusing the existing pull and manifest machinery (`src/skillify/pull.ts`, `src/skillify/manifest.ts`) rather than a parallel writer. +- The bridge runs as part of the same SessionStart auto-pull that already keeps skills fresh (`src/skillify/auto-pull.ts:75-145`), and is idempotent, bounded, and failure-swallowing on that hot path exactly as the existing pull is, so a Cursor session never blocks or breaks because of a sync attempt. +- Any skill that cannot be synced into Cursor's directories is surfaced as a specific, actionable in-editor state, not a swallowed log line, and the PRD-002c status bar reflects whether the team's skills are actually reaching the Cursor agent. +- The active team-wide rules ledger is rendered as a list in the PRD-003 dashboard, drawn from the same `listRules` reader the CLI uses (`src/rules/read.ts:52-84`), and a developer can add, edit, and complete rules graphically through the same `insertRule` / `editRule` / `markRuleDone` writers (`src/rules/write.ts:84-151`). +- Locally mined skills are shown in the dashboard from the same skillify state the CLI reports (`src/commands/skillify.ts:45-96`), and a developer can promote a chosen skill so the team can pull it, through the existing promotion and scope machinery (`src/commands/skillify.ts:122`, `src/skillify/scope-config.ts`), with one click. +- Every surface degrades gracefully: a fresh install with no pulled skills, no rules, and no locally mined skills renders a coherent empty state with guidance, never a crash or a blank pane, matching the never-throw posture of the data and pull layers. + +## Non-Goals + +- **Changing how skills are mined or how rules are authored by agents.** The skillify worker (`src/skillify/skillify-worker.ts`), the local miner (`src/commands/mine-local.ts`), and the SessionStart rule injection (`src/hooks/shared/context-renderer.ts`) are upstream and unchanged. PRD-005 syncs, surfaces, and promotes; it does not re-author the engines. +- **Replacing the `hivemind` CLI as the engine.** The CLI and its modules remain the source of truth for pull, unpull, promote, scope, and rules CRUD. The bridge is a graphical and path-level front-end that calls into and reflects those capabilities. +- **Reworking the cross-agent fan-out for Codex, Hermes, and pi.** Those roots already receive skills correctly (`src/skillify/agent-roots.ts:48-67`). PRD-005 adds Cursor as a destination; it does not redesign how the other agents are served. +- **Designing a new rules data model or scope model.** Rules stay `team`-scoped, append-only, version-bumped, and capped at the existing injection limit (`src/rules/write.ts:84-114`, `src/rules/read.ts:36-38`). Skill scope stays `me | team` with `project | global` install (`src/skillify/scope-config.ts:23-35`). PRD-005 surfaces these; it does not change their semantics. +- **A full skill editor or rules-history browser.** Editing a SKILL.md body in-editor, browsing every historical version of a rule, or resolving merge conflicts visually are later concerns. PRD-005 lists, syncs, adds, edits, completes, and promotes. +- **A VS Code (non-Cursor) release.** The target surface is Cursor 1.7+, matching PRD-002, PRD-003, and PRD-004. The path bridge is, by definition, Cursor-specific. + +--- + +## Sub-features + +| Sub-PRD | Scope | Status | +|---|---|---| +| [`prd-005a-skillify-bridge`](./prd-005a-skillify-bridge.md) | The Skillify Path Bridge: automatically sync or symlink pulled skills into Cursor's active skill directories (`~/.cursor/skills-cursor/`, `<project>/.cursor/skills/`) so Cursor's agent discovers them, reusing the existing pull fan-out and manifest, with honest reporting of any skill that cannot be synced. | Backlog | +| [`prd-005b-rules-manager`](./prd-005b-rules-manager.md) | The Team Rules Manager: render the active team-wide rules ledger in the dashboard and let a developer add, edit, and complete rules graphically over the existing `hivemind rules` engine. | Backlog | +| [`prd-005c-skill-promoter`](./prd-005c-skill-promoter.md) | The Interactive Skill Promoter: show locally mined skills in the dashboard and let a developer promote a chosen skill to the team in one click, reflecting the result back to the auto-pull loop. | Backlog | + +--- + +## The bridge journey (module-level) + +The three sub-features compose into one continuous experience. The path bridge runs both on the SessionStart hot path and on demand from the dashboard; the rules manager and skill promoter are panes in the PRD-003 Webview shell, siblings to the KPI, settings, session, and graph views. + +```mermaid +flowchart TD + session["Cursor session starts"] --> autopull["Auto-pull team skills<br/>(src/skillify/auto-pull.ts)"] + autopull --> canonical["Canonical SKILL.md written under .claude/skills<br/>+ fan-out to Codex / Hermes / pi"] + canonical --> bridge{"Cursor roots reachable?<br/>(PRD-005a)"} + bridge -->|"Yes"| synced["Skills synced into .cursor/skills-cursor + .cursor/skills<br/>Cursor agent discovers them"] + bridge -->|"Blocked (real file / permission)"| reportGap["Honest in-editor state:<br/>'N team skills not reaching Cursor' + fix"] + synced --> status["PRD-002c status bar: team skills reaching Cursor"] + reportGap --> status + open["Developer opens dashboard"] --> rulesPane["Team Rules pane<br/>(PRD-005b)"] + open --> promoterPane["Skill Promoter pane<br/>(PRD-005c)"] + rulesPane --> ruleAction["Add / edit / complete a rule -> hivemind rules engine"] + promoterPane --> promote["Promote a local skill -> team pulls it next session"] +``` + +The defining property carried from PRD-002 through PRD-004: **the surface never leaves the developer guessing.** Where the legacy path would silently land skills in a directory Cursor ignores, or force a CLI command to see or change a rule, the bridge syncs automatically, shows the rules plainly, and is honest the moment a skill cannot reach the Cursor agent. + +--- + +## Personas + +| Persona | Context | What PRD-005 gives them | +|---|---|---| +| **The Cursor-native teammate (Dana)** | Works entirely in Cursor; expects team skills to "just be there." | Pulled team skills appear in her Cursor agent automatically, with no knowledge that `.claude/skills/` and `.cursor/skills/` differ. | +| **The blocked-sync developer (Sam)** | Has a real file sitting where a Cursor skill symlink should go, so a skill silently never syncs. | A specific in-editor message naming the skills that are not reaching Cursor and the conflict to resolve, instead of a false green. | +| **The rule-curious developer (Priya)** | Wonders why her agent keeps following a convention she did not set. | A visible list of the active team rules steering every session, readable at a glance in the dashboard. | +| **The rule author (Marco)** | Wants to add a team convention and close a stale one, hates CLI UUIDs. | Add, edit, and complete rules from the dashboard, no `hivemind rules edit <uuid>` to memorize. | +| **The skill sharer (Lee)** | Mined a genuinely useful skill locally; it is stuck on his machine. | His locally mined skills listed in the dashboard with a one-click promote that puts the skill in the team's reach on their next auto-pull. | + +--- + +## Acceptance criteria (module-level) + +| ID | Criterion | +|---|---| +| AC-1 | Given a developer working in Cursor with pulled team skills, when the bridge runs, then those skills are present in Cursor's active skill directories (`~/.cursor/skills-cursor/` for global, `<project>/.cursor/skills/` for project) so Cursor's agent can discover them, without any manual action. | +| AC-2 | Given the bridge runs as part of SessionStart auto-pull, when it executes, then it is idempotent, bounded by the same timeout budget as the existing pull, and swallows all failures so the Cursor session never blocks or breaks. | +| AC-3 | Given a real file or directory already occupies a Cursor skill path, or a permission/read-only error prevents a sync, when the bridge runs, then the affected skills are reported as not reaching the Cursor agent (never silently dropped) and the PRD-002c status bar reflects a non-green skill-sync state. | +| AC-4 | Given the dashboard is open, when the developer opens the Team Rules pane, then the active team-wide rules render as a list drawn from the same `listRules` reader the CLI uses, newest-first and capped consistently with the SessionStart injection. | +| AC-5 | Given the Team Rules pane, when the developer adds, edits, or completes a rule, then the change is written through the existing `insertRule` / `editRule` / `markRuleDone` engine (append-only, version-bumped) and the list reflects it without a CLI command. | +| AC-6 | Given the dashboard is open, when the developer opens the Skill Promoter pane, then their locally mined skills render from the same skillify state the CLI reports, distinguishing skills already shared with the team from those still local-only. | +| AC-7 | Given a local-only mined skill, when the developer promotes it with one click, then it is shared through the existing promotion and scope machinery so teammates pull it on their next auto-pull, and the pane reflects the new shared state. | +| AC-8 | Given a fresh install with no pulled skills, no rules, and no locally mined skills, when any PRD-005 surface renders, then it shows a coherent empty state with guidance, never a crash or a blank pane. | +| AC-9 | Given any PRD-005 surface, its serialized Webview payload, or its logs are inspected, when their contents are examined, then no token or API key value appears anywhere (defers to PRD-002b secrets rules). | + +--- + +## How PRD-005 reuses what already exists (cross-cutting) + +PRD-005's central discipline, inherited from PRD-004, is consumption, not reinvention. Every new capability maps to an engine the codebase already ships. This table is the contract the sub-PRDs inherit. + +| New capability | Existing artifact it consumes | Source | +|---|---|---| +| Pulling team skills to local disk | `runPull` (query org table, write canonical SKILL.md, decide write/skip by version) | `src/skillify/pull.ts:456-641` | +| Fanning a pulled skill out to other agents | `fanOutSymlinks` + `detectAgentSkillsRoots` (the pattern Cursor is added to) | `src/skillify/pull.ts:216-251`, `src/skillify/agent-roots.ts:48-84` | +| Tracking what was pulled and reversing it | The pull manifest (`PulledEntry.symlinks`, `recordPull`, `unlinkSymlinks`, `pruneOrphanedEntries`) | `src/skillify/manifest.ts:26-251` | +| Keeping skills fresh on every session | The SessionStart auto-pull (bounded, idempotent, failure-swallowing, opt-out) | `src/skillify/auto-pull.ts:75-145` | +| Reading the active team rules | `listRules` (latest-per-rule_id, status filter, newest-first, limit) | `src/rules/read.ts:52-84` | +| Adding / editing / completing a rule | `insertRule` / `editRule` / `markRuleDone` (append-only, version-bumped) | `src/rules/write.ts:84-151` | +| Listing locally mined skills | The skillify per-project state (`skillsGenerated[]`) and scope/install config | `src/commands/skillify.ts:45-96`, `src/skillify/scope-config.ts:46-68` | +| Promoting a skill to the team | `promoteSkill` (project to global) + scope promotion to the org table | `src/commands/skillify.ts:122`, `src/skillify/scope-promotion.ts:34-41`, `src/skillify/skill-org-publish.ts:108-142` | + +--- + +## Cross-cutting requirements + +- **One engine, one source of truth.** The bridge consumes the same org `skills` table, `hivemind_rules` ledger, pull manifest, and canonical config the CLI uses. It maintains no competing store of skills, rules, or pull state. +- **Hot-path safety.** The path bridge inherits the auto-pull contract: bounded by the same timeout budget, idempotent across repeated runs, and all-failures-swallowed so a SessionStart never blocks or fails because of a sync (`src/skillify/auto-pull.ts:10-26`). The opt-out `HIVEMIND_AUTOPULL_DISABLED=1` continues to disable the whole loop. +- **Never clobber user data.** Syncing into Cursor's directories reuses the fan-out's refusal posture: a real (non-symlink) file or directory at a target path is never overwritten; the conflict is reported, not resolved by force (`src/skillify/pull.ts:226-231`). +- **Honesty over optimism.** A skill that cannot reach the Cursor agent is shown as exactly that, never folded into a green "synced" state. The status bar and dashboard never claim the Cursor agent has skills it cannot actually discover. +- **Idempotent writes.** Rule edits reuse the append-only version-bump engine and are safe to repeat; skill syncs reuse the `lstat`-checked, `sameSorted`-skipped symlink machinery and rewrite nothing when the on-disk state already matches (`src/skillify/pull.ts:282-307`). +- **No secret leakage.** No PRD-005 surface, serialized payload, or log renders tokens or API keys; the skills and rules paths are content-only (defers to PRD-002b secrets rules). +- **Lifecycle coherence.** Skill-sync state is reflected consistently by the PRD-002c status bar and the dashboard, and refreshes when an auto-pull or a manual sync runs, so the surfaces never disagree about whether the team's skills are reaching the Cursor agent. + +--- + +## Open questions + +- [ ] Should the Cursor sync use symlinks (matching the existing fan-out to Codex/Hermes/pi, `src/skillify/pull.ts:216-251`) or file copies, given Cursor's skill loader behavior on symlinked directories and the Windows non-developer-mode symlink restriction the fan-out already tolerates (`src/skillify/pull.ts:244-248`)? +- [ ] Is `~/.cursor/skills-cursor/` the correct global Cursor skill root and `<project>/.cursor/skills/` the correct project root for the target Cursor version, and should the bridge detect Cursor's presence by a marker directory the way `detectAgentSkillsRoots` detects Codex/Hermes/pi (`src/skillify/agent-roots.ts:48-67`)? +- [ ] Should adding Cursor be a new entry in `detectAgentSkillsRoots` (so it flows through `fanOutSymlinks` and the manifest automatically) or a dedicated Cursor bridge step, given the agent-roots module documents the deliberate Cursor exclusion that must be reversed (`src/skillify/agent-roots.ts:27-28`)? +- [ ] For project-scoped Cursor skills, how does the bridge know the active project root inside a Cursor Webview, and should project syncs target the workspace folder Cursor currently has open rather than `process.cwd()`? +- [ ] Should the rules manager allow editing a rule's text inline (one `editRule` per save) or batch edits, given each save appends a new version row and a chatty editor could inflate the append-only table (`src/rules/write.ts:161-192`)? +- [ ] The `hivemind skillify promote` command moves a skill project to global on the local filesystem (`src/commands/skillify.ts:122`); reaching the team additionally requires the skill to be authored or republished at `team` scope on the org table (`src/skillify/skill-org-publish.ts:108-142`). Should the one-click promoter do both in sequence, and how should it communicate the two-step nature honestly? +- [ ] Should the Team Rules and Skill Promoter panes be tabs in the single PRD-003 dashboard Webview (alongside KPI, settings, session, and graph) or a dedicated "Team" Webview, given they are write surfaces with different refresh cadences than the read-heavy KPI and graph panes? + +--- + +## Related + +- [`prd-005a-skillify-bridge`](./prd-005a-skillify-bridge.md): the Cursor path bridge for pulled skills. +- [`prd-005b-rules-manager`](./prd-005b-rules-manager.md): the graphical team rules manager. +- [`prd-005c-skill-promoter`](./prd-005c-skill-promoter.md): the interactive skill promoter. +- [`../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 (health, auth, status bar) this bridge integrates with. +- [`../prd-002-cursor-extension-core/prd-002a-health-check.md`](../prd-002-cursor-extension-core/prd-002a-health-check.md): the health check the skill-sync state extends and re-triggers. +- [`../prd-002-cursor-extension-core/prd-002c-status-bar.md`](../prd-002-cursor-extension-core/prd-002c-status-bar.md): the status bar that reflects whether team skills are reaching the Cursor agent. +- [`../prd-003-cursor-extension-dashboard/prd-003-cursor-extension-dashboard-index.md`](../prd-003-cursor-extension-dashboard/prd-003-cursor-extension-dashboard-index.md): the Stage 3 dashboard whose Webview shell hosts the rules and promoter panes. +- [`../prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md`](../prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md): the Webview shell and the "skills created" KPI this stage complements. +- [`../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md`](../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md): the settings manager whose canonical-config and re-health pattern the bridge's sync toggle follows. +- [`../prd-004-cursor-graph-visualizer/prd-004-cursor-graph-visualizer-index.md`](../prd-004-cursor-graph-visualizer/prd-004-cursor-graph-visualizer-index.md): the sibling Stage 4 view sharing the same Webview shell and reuse discipline. +- [`../../../knowledge/private/standards/documentation-framework.md`](../../../knowledge/private/standards/documentation-framework.md): documentation standards this PRD conforms to. +- Source grounding: `src/skillify/pull.ts:216-641` (pull, fan-out, backfill), `src/skillify/agent-roots.ts:27-84` (the deliberate Cursor exclusion this bridge reverses), `src/skillify/auto-pull.ts:75-145` (SessionStart auto-pull this bridge rides), `src/skillify/manifest.ts:26-251` (pull manifest and symlink reversal), `src/skillify/scope-config.ts:23-74` (scope/install config), `src/skillify/scope-promotion.ts:34-41` (me to team promotion), `src/skillify/skill-org-publish.ts:108-142` (republish to org table), `src/commands/skillify.ts:45-122` (status + promote), `src/commands/rules.ts:145-255` (rules CLI), `src/rules/read.ts:52-84` (`listRules`), `src/rules/write.ts:84-151` (`insertRule`/`editRule`/`markRuleDone`), `src/skillify/local-mined-banner.ts:32-40` (the count-only local-skill signal this stage replaces visually). diff --git a/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005a-skillify-bridge.md b/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005a-skillify-bridge.md new file mode 100644 index 00000000..3f836f81 --- /dev/null +++ b/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005a-skillify-bridge.md @@ -0,0 +1,191 @@ +# PRD-005a: Skillify Path Bridge + +> **Status:** Backlog +> **Priority:** P1 +> **Effort:** L (1-3d) +> **Schema changes:** None +> **Parent:** [`prd-005-cursor-skillify-bridge-index`](./prd-005-cursor-skillify-bridge-index.md) + +--- + +## Overview + +This sub-feature is the keystone of PRD-005: it makes team skills actually reach the Cursor agent. Hivemind pulls every teammate's mined skills from the org `skills` table and writes a canonical `SKILL.md` per skill under `~/.claude/skills/<name>--<author>/` (global) or `<project>/.claude/skills/<name>--<author>/` (project), then fans each one out as a symlink into the skill directories of every other installed agent, Codex, Hermes, and pi (`src/skillify/pull.ts:579-585`, `src/skillify/agent-roots.ts:48-67`). Cursor is the one agent it deliberately skips, on an assumption the agent-roots module states in a comment: "Cursor has no native skill discovery (only hooks/rules), so it is not a candidate" (`src/skillify/agent-roots.ts:27-28`). + +That assumption is now false. Cursor's active agent discovers skills in `.cursor/skills/` (per project) and `~/.cursor/skills-cursor/` (globally). Because Hivemind never writes to or links into those paths, a Cursor developer's agent silently lacks every team skill, even though the pull succeeded, the manifest recorded it, and the status bar is green. This sub-feature reverses the exclusion: it syncs pulled skills into Cursor's active directories so the Cursor agent discovers them immediately, reusing the exact fan-out, manifest, and auto-pull machinery that already serves the other agents rather than inventing a parallel writer. + +The value is the difference between a shared brain that is configured and a shared brain that works. After this sub-feature, a skill a teammate mines is usable by a Cursor developer's agent on their next session, automatically, with no awareness that two different skill directories ever existed. + +--- + +## Why this matters: the path gap we are closing + +The pull write path ends by fanning the canonical skill out to every detected non-Claude agent root: + +```216:251:src/skillify/pull.ts +export 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()) { + // Real file/directory at the target — never clobber. Skip silently; + // the user can resolve the conflict by removing it and re-running pull. + continue; + } +``` + +The set of roots it fans out to is computed by `detectAgentSkillsRoots`, which resolves Codex, Hermes, and pi, and explicitly omits Cursor: + +```48:67:src/skillify/agent-roots.ts +function resolveDetected(home: string): string[] { + const out: string[] = []; + const codexInstalled = existsSync(join(home, ".codex")); + const piInstalled = existsSync(join(home, ".pi", "agent")); + const hermesInstalled = existsSync(join(home, ".hermes")); + + // agentskills.io shared root — codex creates it, pi co-consumes it. + if (codexInstalled || piInstalled) { + out.push(join(home, ".agents", "skills")); + } + // Hermes-specific root, agentskills.io-compatible layout. + if (hermesInstalled) { + out.push(join(home, ".hermes", "skills")); + } + // Pi's primary root (pi reads from this AND ~/.agents/skills/). + if (piInstalled) { + out.push(join(home, ".pi", "agent", "skills")); + } + return out; +} +``` + +So the canonical bytes exist under `.claude/skills/`, the symlinks exist under `.agents/`, `.hermes/`, and `.pi/`, and nothing exists under `.cursor/`. The Cursor agent, which only ever looks at `.cursor/skills/` and `~/.cursor/skills-cursor/`, finds nothing. This sub-feature adds Cursor as a sync destination so the same canonical skill the agent VFS, Codex, Hermes, and pi all read is also reachable by Cursor's loader. + +--- + +## Goals + +- Sync every pulled skill into Cursor's active skill directories: a global pull (`install === "global"`) reaches `~/.cursor/skills-cursor/`, and a project pull (`install === "project"`) reaches `<project>/.cursor/skills/`, so Cursor's agent discovers the same canonical skill the other agents already see. +- Reuse the existing fan-out machinery (`fanOutSymlinks`, the `lstat`-checked refusal posture, the manifest `symlinks[]` record) so Cursor sync is reversible by `unpull`, idempotent across runs, and never clobbers a real file at a target path (`src/skillify/pull.ts:216-251`, `src/skillify/manifest.ts:215-251`). +- Ride the existing SessionStart auto-pull (`src/skillify/auto-pull.ts:75-145`) so Cursor sync happens automatically on every session, bounded by the same timeout budget and with all failures swallowed; never introduce a new hot-path step that can block or break a Cursor session. +- Backfill Cursor links for skills already pulled before Cursor was installed or before this bridge shipped, using the same `backfillSymlinks` pass that already serves the late-installed-agent case (`src/skillify/pull.ts:282-307`). +- Produce a structured per-skill sync result the status bar (PRD-002c) and dashboard can read, so any skill that could not reach Cursor is surfaced rather than swallowed. +- Honor the global opt-out (`HIVEMIND_AUTOPULL_DISABLED=1`) and the project/global scoping that keeps a project pull from leaking into user-global agent directories (`src/skillify/pull.ts:581-585`). + +## Non-Goals + +- **Changing how skills are pulled or written.** The query, the version-decision, and the canonical `SKILL.md` write are unchanged (`src/skillify/pull.ts:456-578`). This sub-feature only adds Cursor to the set of destinations the existing fan-out serves. +- **Reworking the Codex / Hermes / pi fan-out.** Those roots are correct as-is (`src/skillify/agent-roots.ts:48-67`). Cursor is added alongside them; their behavior does not change. +- **Authoring or rendering the status indicator.** Presentation belongs to PRD-002c. This sub-feature produces a structured sync result; the status bar displays it. +- **Resolving sync conflicts by force.** When a real file or directory blocks a Cursor target, this sub-feature reports it; it never overwrites user content, matching the fan-out's existing refusal posture (`src/skillify/pull.ts:226-231`). +- **Project-root discovery inside the Webview.** Determining which folder a project-scoped Cursor sync should target inside a Cursor Webview is an open question routed to the parent index; this sub-feature defines the behavior given a known root. + +--- + +## How Cursor joins the fan-out + +The cleanest implementation extends the existing root detection so Cursor flows through `fanOutSymlinks`, the manifest, and `backfillSymlinks` automatically, exactly as the other agents do. Two roots are added, gated by install scope. + +| Pull scope | Cursor destination | Why | +|---|---|---| +| `install === "global"` | `~/.cursor/skills-cursor/<name>--<author>/` | Cursor's global skill root; visible across all projects, matching how global pulls reach `~/.agents/skills/` today. | +| `install === "project"` | `<project>/.cursor/skills/<name>--<author>/` | Cursor's per-project skill root; lives with the repo, matching the project-scoping that keeps project pulls out of user-global agent dirs (`src/skillify/pull.ts:581-585`). | + +Detection mirrors the marker-based approach `detectAgentSkillsRoots` already uses for the other agents: a Cursor install is recognized by a marker directory (for example `~/.cursor`), and the global Cursor root is added to the fan-out set whenever Cursor is detected. The project root is derived from the same `cwd` the project pull already uses (`src/skillify/pull.ts:190-194`). As with the other agents, `fanOutSymlinks` calls `mkdirSync(dirname(link), { recursive: true })` before each link, so a not-yet-created `skills-cursor/` directory is created on first sync (`src/skillify/pull.ts:242-244`). + +The reuse boundary matters: by routing Cursor through the same `fanOutSymlinks` call and recording the resulting paths in the manifest's `symlinks[]` (`src/skillify/manifest.ts:43-54`), Cursor links are automatically reversed by `unpull` (`src/skillify/manifest.ts:215-222`), pruned when a canonical dir is removed (`src/skillify/manifest.ts:235-251`), and backfilled for already-pulled skills (`src/skillify/pull.ts:282-307`). No parallel bookkeeping is introduced. + +--- + +## The sync segment owned here + +```mermaid +sequenceDiagram + participant Hook as SessionStart auto-pull + participant Pull as "runPull (src/skillify/pull.ts)" + participant Roots as "detectAgentSkillsRoots (+ Cursor)" + participant FS as "~/.claude/skills (canonical)" + participant Cur as "~/.cursor/skills-cursor + .cursor/skills" + participant Man as "pulled.json manifest" + + Hook->>Pull: pull --all-users --to global (bounded, swallow-all) + Pull->>FS: write canonical SKILL.md (write/skip by version) + Pull->>Roots: resolve destinations (Codex, Hermes, pi, Cursor) + Pull->>Cur: fanOutSymlinks (lstat-checked, never clobber) + alt Cursor target free or already correct + Cur-->>Pull: link path + Pull->>Man: record symlinks[] (incl. Cursor) + else Real file blocks the Cursor target + Cur-->>Pull: refused (not in returned list) + Pull->>Man: record without that path; result flags unsynced skill + end + Pull-->>Hook: PullSummary + per-skill sync result +``` + +The whole segment inherits the auto-pull contract: bounded by `DEFAULT_TIMEOUT_MS` (`src/skillify/auto-pull.ts:35,118-138`), idempotent because the symlink path is `lstat`-checked and `sameSorted`-skipped (`src/skillify/pull.ts:235-237,291`), and failure-swallowing because the whole loop is wrapped (`src/skillify/auto-pull.ts:141-144`). + +--- + +## Honest reporting of unsynced skills + +The fan-out's refusal posture is correct (never clobber user data), but today a refusal is silent: a skipped Cursor link just does not appear in the returned path list (`src/skillify/pull.ts:226-231,246-248`). For Cursor, silence reproduces the very problem PRD-005 exists to kill, the developer would again have a green status and a skill-less agent. So this sub-feature makes Cursor sync failures legible. + +1. **Per-skill sync state.** The pull result carries, for each skill, whether its Cursor destination was reached, skipped (a real file blocks the path), or errored (permission, read-only filesystem, Windows non-developer-mode symlink restriction the fan-out already tolerates, `src/skillify/pull.ts:244-248`). +2. **Status-bar signal.** When one or more skills cannot reach Cursor, PRD-002c shows a non-green skill-sync state ("N team skills not reaching Cursor"), consistent with the index's honesty-over-optimism rule. A fully-synced state contributes to green. +3. **Actionable detail.** The dashboard can list the specific skills that did not sync and the reason (for a blocking file, name the conflicting path so the developer can remove it and re-sync), mirroring the fan-out comment's own guidance that the user resolves the conflict by removing the entry and re-running pull (`src/skillify/pull.ts:228-230`). +4. **Never a false green.** The status bar and dashboard never report Cursor skills as available when the sync was refused or errored. + +--- + +## Presentation and behavior requirements + +- **Invisible when it works.** The happy path requires no developer action and no UI: skills appear in Cursor's directories on session start. The bridge surfaces itself only when a skill cannot reach Cursor. +- **Idempotent and quiet on the hot path.** Re-running a sync when links already point correctly is a no-op with zero writes (`src/skillify/pull.ts:235-237`); the bridge adds no per-session log noise on success. +- **Reversible.** `unpull` removes Cursor links through the same manifest-driven unlink pass as the other agents (`src/skillify/manifest.ts:215-222`); no Cursor-specific teardown path is introduced. +- **Scope-respecting.** A project pull never leaks skills into the global Cursor root, and a global pull never writes into a project's `.cursor/skills/`, matching the existing project/global discipline (`src/skillify/pull.ts:581-585`). +- **No secret leakage.** The sync touches only skill content on local disk; no token or API key appears in the manifest, the sync result, or any log (defers to PRD-002b). + +--- + +## Acceptance criteria + +| ID | Criterion | +|---|---| +| AC-1 | Given a global pull and a detected Cursor install, when the bridge runs, then each pulled skill is linked into `~/.cursor/skills-cursor/<name>--<author>/` so Cursor's agent can discover it. | +| AC-2 | Given a project pull, when the bridge runs, then each pulled skill is linked into `<project>/.cursor/skills/<name>--<author>/`, and a project pull never writes into the global Cursor root. | +| AC-3 | Given the bridge rides the SessionStart auto-pull, when a session starts, then Cursor sync happens automatically within the existing timeout budget and any failure is swallowed so the session never blocks or breaks. | +| AC-4 | Given a skill was pulled before Cursor was installed, when a later pull's backfill runs, then the missing Cursor link is created without requiring the source row's version to bump. | +| AC-5 | Given a real (non-symlink) file or directory occupies a Cursor target path, when the bridge runs, then it does not overwrite it, and the affected skill is reported as not reaching Cursor with the conflicting path named. | +| AC-6 | Given a Cursor link already points at the correct canonical directory, when the bridge runs again, then no write occurs (idempotent, fingerprint-preserving). | +| AC-7 | Given one or more skills cannot reach Cursor, when the health/sync state is computed, then the PRD-002c status bar reflects a non-green skill-sync state rather than a false green. | +| AC-8 | Given `unpull` is run, when it removes a pulled skill, then its Cursor link is removed through the same manifest-driven unlink pass as the Codex/Hermes/pi links. | +| AC-9 | Given `HIVEMIND_AUTOPULL_DISABLED=1`, when a session starts, then neither the pull nor the Cursor sync runs. | +| AC-10 | Given the manifest, sync result, or any log is inspected, when its contents are examined, then no token or API key value appears. | + +--- + +## Open questions + +- [ ] Symlink versus copy into Cursor's directories: does Cursor's skill loader follow symlinked skill directories reliably across platforms, or should the Cursor destination be a file copy (losing the single-source-of-truth symlink guarantee but avoiding the Windows non-developer-mode symlink restriction the fan-out tolerates, `src/skillify/pull.ts:244-248`)? +- [ ] Is the global root `~/.cursor/skills-cursor/` and the project root `<project>/.cursor/skills/` correct for the target Cursor version, and what marker directory most reliably detects a Cursor install for `detectAgentSkillsRoots` (`src/skillify/agent-roots.ts:48-67`)? +- [ ] Should Cursor be added directly to `detectAgentSkillsRoots` (so it flows through `fanOutSymlinks`, the manifest, and `backfillSymlinks` with no other code change) or kept as a dedicated bridge step, given the module's explicit comment documenting the deliberate Cursor exclusion that must now be reversed (`src/skillify/agent-roots.ts:27-28`)? +- [ ] For project-scoped Cursor sync inside a Webview, should the target be the workspace folder Cursor currently has open rather than `process.cwd()`, and how is that folder resolved from the extension host? +- [ ] Should a Cursor reload be prompted after the first sync for the agent to pick up newly linked skills, or does Cursor's loader scan the directory per session without a reload (mirroring the reload-awareness requirement in PRD-002a, `prd-002a-health-check.md`)? + +--- + +## Related + +- [`prd-005-cursor-skillify-bridge-index`](./prd-005-cursor-skillify-bridge-index.md): parent module. +- [`prd-005c-skill-promoter`](./prd-005c-skill-promoter.md): once a skill is promoted to the team, this bridge is what makes it reach the promoter's own Cursor agent on the next pull. +- [`../prd-002-cursor-extension-core/prd-002a-health-check.md`](../prd-002-cursor-extension-core/prd-002a-health-check.md): the health check whose four dimensions the skill-sync state extends, and whose hook-wiring reload pattern this bridge mirrors. +- [`../prd-002-cursor-extension-core/prd-002c-status-bar.md`](../prd-002-cursor-extension-core/prd-002c-status-bar.md): consumes this sub-feature's structured sync result. +- [`../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md`](../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md): the settings surface where a manual "sync skills to Cursor" action and a sync toggle live, following its canonical-config and re-health pattern. +- Source grounding: `src/skillify/pull.ts:190-307,456-641` (pull, fan-out, backfill, project/global scoping), `src/skillify/agent-roots.ts:27-84` (the deliberate Cursor exclusion this reverses and the marker-detection pattern to extend), `src/skillify/auto-pull.ts:35,75-145` (the SessionStart auto-pull this rides, its timeout and swallow-all contract), `src/skillify/manifest.ts:26-251` (`PulledEntry.symlinks`, `recordPull`, `unlinkSymlinks`, `pruneOrphanedEntries`), `src/cli/skillify-spec.ts:45-49` (the `pull --to project|global` surface this honors). diff --git a/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005b-rules-manager.md b/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005b-rules-manager.md new file mode 100644 index 00000000..eae145b9 --- /dev/null +++ b/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005b-rules-manager.md @@ -0,0 +1,191 @@ +# PRD-005b: Team Rules Manager + +> **Status:** Backlog +> **Priority:** P2 +> **Effort:** L (1-3d) +> **Schema changes:** None +> **Parent:** [`prd-005-cursor-skillify-bridge-index`](./prd-005-cursor-skillify-bridge-index.md) + +--- + +## Overview + +This sub-feature surfaces the team-wide rules ledger inside the dashboard and turns it into a graphical surface a developer can read and edit. Hivemind already keeps a `hivemind_rules` table: an append-only, version-bumped ledger where each edit appends a fresh row and reads take the latest version per `rule_id` (`src/rules/read.ts:1-13`). The active rules are injected into every session's context, capped at ten and newest-first, so they actively steer how each agent behaves (`src/rules/read.ts:36-38,80-83`). The engine is complete: `listRules` reads, and `insertRule`, `editRule`, and `markRuleDone` write (`src/rules/write.ts:84-151`). The only surface today is the CLI: `hivemind rules list`, `add`, `edit <uuid>`, `done <uuid>` (`src/commands/rules.ts:37-45`). + +The result is that the rules governing a developer's every session are invisible to them inside Cursor, and changing one means dropping to a terminal and copy-pasting a 36-character UUID into an `edit` or `done` command (`src/commands/rules.ts:133-143`). This sub-feature renders the active ledger as a list in the PRD-003 dashboard Webview and exposes add, edit, and complete as graphical actions over the unchanged engine, so a developer can see exactly what conventions are shaping their agents and curate them without leaving the editor. + +The value is legibility and control of the thing steering the team's agents. A rule is no longer an invisible force a developer stumbles into; it is a visible, editable line in a list they own. + +--- + +## Why this matters: the invisible governor + +Rules are not passive notes. They are injected into the model-visible context at the start of every session, deduplicated to the latest version per rule and capped: + +```52:84:src/rules/read.ts +export async function listRules( + query: QueryFn, + tableName: string, + opts: ListRulesOpts = {}, +): Promise<RuleRow[]> { + const safe = sqlIdent(tableName); + const rows = await query( + `SELECT ${SELECT_COLS} FROM "${safe}" ORDER BY version DESC, created_at DESC, id DESC`, + ); + + const latest = new Map<string, RuleRow>(); + for (const r of rows) { + const row = normalize(r); + if (!row) continue; + if (!latest.has(row.rule_id)) latest.set(row.rule_id, row); + } + + const statusFilter = opts.status ?? "active"; + const filtered = [...latest.values()].filter(r => + statusFilter === "all" ? true : r.status === statusFilter, + ); + + filtered.sort( + (a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id), + ); + return filtered.slice(0, opts.limit ?? 10); +} +``` + +Writes are append-only by design, because the Deeplake backend coalesces rapid UPDATEs on the same row; every edit inserts a new version instead (`src/rules/write.ts:1-11,121-137`). The CLI is the only way to drive any of this today, and it requires the full UUID because edit and done do an exact-match select on `rule_id` (`src/commands/rules.ts:133-143`). This sub-feature gives the same engine a face: the list a developer can see, and the buttons that call the same writers without a UUID ever touching their clipboard. + +--- + +## Goals + +- Render the active team-wide rules as a list in the PRD-003 dashboard Webview, drawn from the same `listRules` reader the CLI uses, newest-first and consistent with the SessionStart injection cap so a developer sees what the agent actually sees (`src/rules/read.ts:52-84`). +- Let a developer add a new rule from the dashboard, writing through `insertRule` with their identity as `assigned_by` and the plugin version stamped, exactly as the CLI `add` path does (`src/commands/rules.ts:174-180`, `src/rules/write.ts:84-114`). +- Let a developer edit a rule's text from the dashboard, writing through `editRule` so a new version row is appended and the `rule_id` is preserved, with no UUID handling required of the developer (`src/rules/write.ts:121-137`). +- Let a developer mark a rule complete from the dashboard, writing through `markRuleDone` so an audit-trail version row is appended (`src/rules/write.ts:145-151`). +- Enforce the engine's input contract in the UI: the 2000-character cap and the no-newlines rule (one rule per line, a prompt-injection defense) are surfaced as inline validation before a write is attempted (`src/rules/write.ts:51-77`). +- Offer a status filter (active, done, all) and a coherent empty state, matching the CLI's `--status` and "(no rules ...)" behavior (`src/commands/rules.ts:188-205`). + +## Non-Goals + +- **Changing the rules data model or scope.** Rules stay `team`-scoped, append-only, and version-bumped. v1 hardcodes scope to `team` (`src/rules/write.ts:104`, `src/commands/rules.ts:75-85`); this sub-feature does not introduce per-rule scoping or a new schema. +- **Changing how rules are injected into sessions.** The SessionStart renderer and its cap are upstream (`src/hooks/shared/context-renderer.ts`, `src/rules/read.ts:36-38`). This sub-feature surfaces and edits the ledger; it does not change what gets injected or how. +- **A full version-history browser.** Each edit appends a version row, but browsing or diffing the full history of a rule is out of scope. This sub-feature shows the latest version per rule, matching `listRules`. +- **Relaxing the input constraints.** The 2000-character cap and newline rejection are defense-in-depth against prompt injection (`src/rules/write.ts:65-77`); the UI enforces them and never bypasses them. +- **Authoring the Webview shell.** The shell, theming, and refresh lifecycle are PRD-003a's. This pane lives inside that shell; it does not re-implement it. + +--- + +## The rule list and its actions + +The pane is a list of rule rows plus three actions. Each action maps one-to-one to an existing engine function, so the pane introduces no new write logic. + +| UI element | Reads / writes | Engine function | Notes | +|---|---|---|---| +| Rule list (active by default) | Reads | `listRules(query, tableName, { status, limit })` | Newest-first, latest-per-`rule_id`, capped consistently with the injection limit (`src/rules/read.ts:80-83`). | +| Per-row metadata | Reads | `RuleRow` fields | Shows `text`, `assigned_by`, `version`, and `status`; the `rule_id` is held internally for edit/done, never shown as a thing to copy. | +| Status filter | Reads | `ListRulesOpts.status` (`active \| done \| all`) | Mirrors the CLI `--status` flag (`src/commands/rules.ts:87-95`). | +| Add rule | Writes | `insertRule({ text, assigned_by, plugin_version })` | `assigned_by` is the logged-in user; scope is hardcoded `team`; status starts `active` (`src/rules/write.ts:84-114`). | +| Edit rule text | Writes | `editRule({ rule_id, text, assigned_by, plugin_version })` | Appends version+1, preserves `rule_id`; the developer edits text, never the UUID (`src/rules/write.ts:121-137`). | +| Complete rule | Writes | `markRuleDone({ rule_id, assigned_by, plugin_version })` | Appends a `done` version row; safe to re-complete (audit trail) (`src/rules/write.ts:145-151`). | + +After any write, the pane re-runs `listRules` so the list reflects the new state without a CLI command, matching the dashboard's never-leave-the-developer-guessing posture. + +--- + +## Input validation surfaced inline + +The engine rejects two classes of input, and the pane must enforce both before a write so the developer sees a clear inline error rather than a failed write: + +```65:77:src/rules/write.ts +function assertValidText(text: string): void { + if (text.length === 0) throw new Error("Rule text must not be empty"); + if (text.length > MAX_TEXT_LENGTH) { + throw new Error(`Rule text exceeds ${MAX_TEXT_LENGTH} chars (got ${text.length})`); + } + if (/[\r\n\u2028\u2029\u0085]/.test(text)) { + throw new Error("Rule text must not contain newlines (use one rule per line)"); + } +} +``` + +1. **Length cap.** The editor shows a live character count against the 2000-character ceiling and blocks submission past it (`MAX_TEXT_LENGTH`, `src/rules/write.ts:51,67-69`). +2. **No newlines.** The input rejects carriage returns, line feeds, and the Unicode line separators the engine guards against, with the engine's own guidance ("use one rule per line"). This is a prompt-injection defense, not a cosmetic rule, so the UI never strips-and-submits silently (`src/rules/write.ts:70-76`). +3. **Non-empty.** Empty text is blocked with the same message the engine would raise. + +--- + +## The pane's data flow + +```mermaid +sequenceDiagram + participant Dev as Developer + participant Pane as "Team Rules pane" + participant Read as "listRules (src/rules/read.ts)" + participant Write as "insertRule / editRule / markRuleDone" + participant Tbl as "hivemind_rules table" + + Dev->>Pane: Open Team Rules pane + Pane->>Read: list (status=active, limit) + Read->>Tbl: SELECT (latest per rule_id) + Tbl-->>Read: rows + Read-->>Pane: active rules (newest-first) + Pane-->>Dev: rule list + Add / Edit / Complete + Dev->>Pane: Add / Edit / Complete (validated inline) + alt valid input + Pane->>Write: insert / edit / done (assigned_by = user) + Write->>Tbl: INSERT new version row + Pane->>Read: re-list + Read-->>Pane: updated rules + Pane-->>Dev: list reflects the change + else invalid (empty / >2000 / newline) + Pane-->>Dev: inline error, no write attempted + end +``` + +--- + +## Presentation requirements + +- **Native-feeling and readable.** The pane respects Cursor's theme and editor tokens and reads as a first-party surface, consistent with PRD-003a's presentation requirements. +- **No UUID handling.** The developer acts on a rule by its row, never by copy-pasting a UUID; the `rule_id` is internal plumbing (contrast the CLI, which prints the full UUID precisely because edit/done need it, `src/commands/rules.ts:133-143`). +- **Honest empty and not-logged-in states.** No rules renders a coherent empty state; not being logged in surfaces the same guidance the CLI gives ("Not logged in. Run `hivemind login`," `src/commands/rules.ts:56-62`) rather than a blank or broken pane. A legacy install whose rules table does not exist yet renders as an empty list, matching the CLI's missing-table tolerance (`src/commands/rules.ts:194-198`). +- **Optimistic but truthful.** A write shows an in-flight state and reconciles against a fresh `listRules` on completion; a failed write surfaces the engine's error message and leaves the list unchanged. +- **No secret leakage.** The serialized pane payload and any logs show rule text and author name only, never tokens or API keys (defers to PRD-002b). +- **Accessible.** The list is keyboard-navigable and each action is reachable without a pointer; status is conveyed by label, not color alone. + +--- + +## Acceptance criteria + +| ID | Criterion | +|---|---| +| AC-1 | Given a logged-in developer, when the Team Rules pane opens, then the active rules render newest-first from `listRules`, capped consistently with the SessionStart injection limit. | +| AC-2 | Given the pane, when the developer adds a rule, then `insertRule` is called with their identity as `assigned_by` and a `team` scope, and the new rule appears in the list without a CLI command. | +| AC-3 | Given an existing rule, when the developer edits its text, then `editRule` appends a new version row, preserves the `rule_id`, and the list shows the updated text and bumped version. | +| AC-4 | Given an existing rule, when the developer marks it complete, then `markRuleDone` appends a `done` version row and the rule leaves the active list (and appears under the done filter). | +| AC-5 | Given the developer types rule text exceeding 2000 characters or containing a newline, when they attempt to submit, then the pane blocks the write and shows an inline error matching the engine's contract. | +| AC-6 | Given the status filter, when the developer selects active, done, or all, then the list reflects that filter, mirroring the CLI `--status` behavior. | +| AC-7 | Given no rules exist (or a legacy install with no rules table), when the pane renders, then it shows a coherent empty state, not a crash or a blank pane. | +| AC-8 | Given the developer is not logged in, when the pane renders, then it surfaces the same login guidance the CLI gives rather than failing silently. | +| AC-9 | Given the pane payload or logs are inspected, when their contents are examined, then no token or API key value appears, and rules are shown by text and author name only. | + +--- + +## Open questions + +- [ ] Should rule editing be inline-per-save (one `editRule` per edit, simplest and matching the CLI) or batched, given each save appends a version row and a chatty editor could inflate the append-only table (`src/rules/write.ts:161-192`)? +- [ ] Should the pane expose the `--limit` control the CLI has (`src/commands/rules.ts:97-108`), or always show the injection-cap default of ten so the developer sees exactly what the agent sees (`src/rules/read.ts:83`)? +- [ ] When a developer edits a rule another teammate also edited (a racing version bump), how should the pane present the resulting two-rows-at-same-version case the readers already tie-break deterministically (`src/rules/read.ts:98-104`)? +- [ ] Should completed rules be hideable/restorable from the pane (re-activating a done rule via `editRule` with status active), or is completion treated as terminal in the UI even though the engine allows re-opening? +- [ ] Should the pane visually mark which rules are currently being injected (the top-ten newest-first) versus active-but-below-the-cap rules, so a developer understands why a low-priority active rule may not be steering sessions? + +--- + +## Related + +- [`prd-005-cursor-skillify-bridge-index`](./prd-005-cursor-skillify-bridge-index.md): parent module. +- [`prd-005c-skill-promoter`](./prd-005c-skill-promoter.md): the sibling write surface in the same "Team" area of the dashboard. +- [`../prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md`](../prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md): the Webview shell this pane lives in. +- [`../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md`](../prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md): the sibling settings surface whose write-then-reflect pattern this pane follows. +- [`../prd-002-cursor-extension-core/prd-002b-auth-secrets.md`](../prd-002-cursor-extension-core/prd-002b-auth-secrets.md): owns the login state and identity (`assigned_by`) this pane's writes depend on. +- Source grounding: `src/rules/read.ts:19-139` (`RuleRow`, `listRules`, `getRuleLatest`, normalization), `src/rules/write.ts:51-195` (`assertValidText`, `insertRule`, `editRule`, `markRuleDone`, the append-only rationale), `src/commands/rules.ts:37-255` (the CLI surface this pane supersedes in-editor, including the UUID-copy ergonomics it removes), `src/rules/index.ts:1-19` (the stable barrel the pane imports from). diff --git a/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005c-skill-promoter.md b/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005c-skill-promoter.md new file mode 100644 index 00000000..a47e089b --- /dev/null +++ b/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005c-skill-promoter.md @@ -0,0 +1,177 @@ +# PRD-005c: Interactive Skill Promoter + +> **Status:** Backlog +> **Priority:** P2 +> **Effort:** L (1-3d) +> **Schema changes:** None +> **Parent:** [`prd-005-cursor-skillify-bridge-index`](./prd-005-cursor-skillify-bridge-index.md) + +--- + +## Overview + +This sub-feature shows a developer the skills they have mined locally and lets them share a chosen one with the team in a single click. Hivemind mines reusable skills from real sessions, but a newly mined skill defaults to `me` scope and the `project` install location (`src/skillify/scope-config.ts:44`), so it lives only on the machine that mined it and in the repo it was mined from. Sharing it with the team is a multi-step CLI affair: the only signal the skill even exists is a count-only SessionStart banner ("N local skills ... Run `hivemind login` to start sharing," `src/skillify/local-mined-banner.ts:32-40`), and acting on it means knowing the combination of `hivemind skillify promote <name>`, `scope team`, and `install global` (`src/cli/skillify-spec.ts:53-56`). + +This sub-feature renders the developer's locally mined skills as a list in the PRD-003 dashboard Webview, drawn from the same skillify state the CLI reports (`src/commands/skillify.ts:45-96`), and exposes promotion as a button. Promotion is honest about being a two-step reality: `hivemind skillify promote` moves a skill from the project location to the global location on the local filesystem so it is visible across all the developer's projects and agents (`src/commands/skillify.ts:122-137`), and reaching teammates additionally requires the skill to be shared at `team` scope on the org `skills` table so it lands in everyone's auto-pull (`src/skillify/scope-promotion.ts:34-41`, `src/skillify/skill-org-publish.ts:108-142`). The promoter drives both and tells the developer plainly what each step does. + +The value is that good local skills stop dying on the machine that mined them. The path from "I mined something useful" to "my team has it" becomes visible and one click long, and (closing the loop with PRD-005a) once shared, the skill flows back into the promoter's own Cursor agent on the next pull. + +--- + +## Why this matters: skills that never leave the laptop + +A mined skill defaults to the narrowest, most local scope: + +```44:44:src/skillify/scope-config.ts +const DEFAULT: ScopeConfig = { scope: "me", team: [], install: "project" }; +``` + +The only nudge a developer gets that they even have shareable skills is a static, count-only banner with no names and no action beyond "log in": + +```32:40:src/skillify/local-mined-banner.ts +export function renderLocalMinedNote(input: LocalMinedBannerInput): string { + const { totalCount } = input; + if (totalCount <= 0) return ""; + const plural = totalCount === 1 ? "" : "s"; + return ( + `\n\n${totalCount} local skill${plural} from past 'hivemind skillify mine-local' run(s) live in ~/.claude/skills/. ` + + `Run 'hivemind login' to start sharing new mining results with your team.` + ); +} +``` + +And `promote` itself only moves a skill project-to-global on disk; it refuses to overwrite and does not, by itself, publish to the org table: + +```122:137:src/commands/skillify.ts +function promoteSkill(name: string, cwd: string): void { + if (!name) { console.error("Usage: hivemind skillify promote <skill-name>"); process.exit(1); } + const projectPath = join(cwd, ".claude", "skills", name); + const globalPath = join(homedir(), ".claude", "skills", name); + if (!existsSync(join(projectPath, "SKILL.md"))) { + console.error(`Skill '${name}' not found at ${projectPath}/SKILL.md`); + process.exit(1); + } + if (existsSync(join(globalPath, "SKILL.md"))) { + console.error(`Skill '${name}' already exists at ${globalPath}/SKILL.md — refusing to overwrite. Remove it first or rename the project skill.`); + process.exit(1); + } + mkdirSync(dirname(globalPath), { recursive: true }); + renameSync(projectPath, globalPath); + console.log(`Promoted '${name}' from ${projectPath} → ${globalPath}.`); +} +``` + +So today a developer would have to know that mining wrote at `me`/`project` scope, that the banner's count maps to specific skills, that `promote` only moves on disk, and that reaching the team additionally needs `team` scope on the org table. This sub-feature collapses all of that into a visible list and an honest one-click action. + +--- + +## Goals + +- Render the developer's locally mined skills as a list in the PRD-003 dashboard Webview, drawn from the same skillify per-project state the CLI reports (`skillsGenerated[]` and the scope/install config, `src/commands/skillify.ts:45-96`, `src/skillify/scope-config.ts:46-68`). +- Distinguish, per skill, whether it is still local-only (`me` scope and/or project install) or already shared with the team, so the developer knows which skills the team can already pull and which are stuck. +- Promote a chosen skill with one click, driving the existing machinery: move project-to-global on the filesystem via the `promote` path (`src/commands/skillify.ts:122-137`) and share it at `team` scope on the org table so teammates pull it on their next auto-pull (`src/skillify/scope-promotion.ts:34-41`, `src/skillify/skill-org-publish.ts:108-142`). +- Be honest about the two steps: the pane communicates that promotion makes the skill visible across the developer's own projects/agents and shares it with the team, and reflects the resulting state rather than implying a single atomic operation. +- Close the loop with PRD-005a: once a skill is shared, it flows through auto-pull back into the developer's own Cursor agent (and every teammate's), so the promoter's reach is consistent with the rest of the bridge. +- Reuse `promote`'s refusal posture: a name collision at the global location is surfaced, never resolved by overwrite (`src/commands/skillify.ts:130-133`). + +## Non-Goals + +- **Mining skills.** The local miner (`src/commands/mine-local.ts`) and the skillify worker (`src/skillify/skillify-worker.ts`) are upstream and unchanged. This sub-feature surfaces and promotes what mining produced; it does not mine. +- **Editing skill content.** Changing a SKILL.md body before promotion is out of scope; the promoter promotes the skill as mined. +- **Designing the scope or publish model.** Scope stays `me | team`, install stays `project | global` (`src/skillify/scope-config.ts:23-35`), and republishing to the org table follows the existing cross-author/scope-promotion rules (`src/skillify/scope-promotion.ts`, `src/skillify/skill-org-publish.ts`). This sub-feature drives that machinery; it does not redesign it. +- **Unpromoting / unsharing.** Reversing a promotion (removing a skill from the org table or moving it back to project) is out of scope; promotion is treated as forward-only here. +- **Authoring the Webview shell.** The shell, theming, and refresh lifecycle are PRD-003a's; this pane lives inside that shell. + +--- + +## The local-skills list and the promote action + +The pane lists locally mined skills and offers a single promote action per skill. Each piece maps to existing state or an existing command path. + +| UI element | Reads / writes | Existing artifact | Notes | +|---|---|---|---| +| Local skills list | Reads | Skillify per-project state (`skillsGenerated[]`), scope/install config | The same data `hivemind skillify` status prints (`src/commands/skillify.ts:74-95`); replaces the count-only banner (`src/skillify/local-mined-banner.ts:32-40`) with named, actionable rows. | +| Shared-or-local badge | Reads | `scope` (`me` vs `team`) and `install` (`project` vs `global`) | Tells the developer which skills the team can already pull and which are stuck local-only (`src/skillify/scope-config.ts:26-35`). | +| Promote (step 1) | Writes | `promoteSkill` (project to global on disk) | Makes the skill visible across all the developer's projects and agents; refuses to overwrite an existing global skill of the same name (`src/commands/skillify.ts:122-137`). | +| Promote (step 2) | Writes | Scope promotion to `team` + republish to the org `skills` table | Lands the skill on the org table at `team` scope so teammates pull it next session (`src/skillify/scope-promotion.ts:34-41`, `src/skillify/skill-org-publish.ts:108-142`). | +| Result reflection | Reads | Refreshed skillify state | The row updates to "shared with team," and (via PRD-005a) the skill is now in the developer's own Cursor agent on next pull. | + +The honesty discipline: the pane never presents step 1 alone as "shared with the team." A skill promoted only project-to-global is visible to the developer's other projects and agents but is not yet on the org table; the pane labels that state accurately and offers the team-share step. + +--- + +## The promotion flow + +```mermaid +flowchart TD + open["Developer opens Skill Promoter pane"] --> list["List locally mined skills<br/>(skillify state)"] + list --> badge{"Skill state?"} + badge -->|"me / project (local-only)"| stuck["Badge: 'on this machine only'"] + badge -->|"team / global (shared)"| shared["Badge: 'team can pull this'"] + stuck --> click["Developer clicks Promote"] + click --> step1["Step 1: project -> global on disk<br/>(promoteSkill)"] + step1 --> collide{"Global name collision?"} + collide -->|"Yes"| refuse["Surface refusal, do not overwrite"] + collide -->|"No"| step2["Step 2: scope -> team, republish to org table"] + step2 --> pull["Teammates + this dev auto-pull it next session<br/>(PRD-005a syncs it into Cursor)"] + pull --> reflect["Pane reflects: 'shared with team'"] +``` + +--- + +## Honest two-step communication + +Because `promote` and team-sharing are genuinely two operations on two different stores (local filesystem versus the org `skills` table), the pane must not paper over the seam: + +1. **Name the effect of each step.** Step 1 is described as "make available across your projects and agents"; step 2 as "share with your team so they can pull it." A developer who only wants local-global promotion can stop after step 1; a developer who wants the team to have it does both. +2. **Reflect the true post-state.** After a one-click promote the pane shows the skill's actual resulting state (shared with team, or global-only if step 2 was skipped or failed), never an optimistic "done" that overstates reach. +3. **Surface refusals, not silence.** A global name collision is reported with the same guidance the command gives ("already exists ... remove it first or rename the project skill," `src/commands/skillify.ts:131`); a failed org-table publish leaves the local promotion intact and reports that team-sharing did not complete. +4. **Close the loop visibly.** Once shared, the pane notes that the skill will reach the developer's own Cursor agent (and teammates') via auto-pull and the PRD-005a path bridge, so the developer sees the full circle rather than wondering whether sharing "took." + +--- + +## Presentation requirements + +- **Named and actionable, not a count.** The pane replaces the static count-only banner (`src/skillify/local-mined-banner.ts:32-40`) with named skill rows the developer can act on. +- **Native-feeling.** Respects Cursor's theme and editor tokens; reads as a first-party surface, consistent with PRD-003a. +- **Truthful state badges.** Every row's local-only versus shared state is shown and is derived from real scope/install state, never assumed. +- **In-flight and error states.** A promote shows progress and reconciles against refreshed skillify state on completion; failures surface the underlying message and leave the skill in its prior state. +- **Not-logged-in honesty.** Sharing to the org table requires login; if the developer is not logged in, the pane offers the same path PRD-002b owns rather than failing silently, mirroring the banner's own "run `hivemind login`" intent (`src/skillify/local-mined-banner.ts:38-39`). +- **No secret leakage.** The pane payload and logs show skill names, scope, and install state only, never tokens or API keys (defers to PRD-002b). + +--- + +## Acceptance criteria + +| ID | Criterion | +|---|---| +| AC-1 | Given a developer who has mined skills locally, when the Skill Promoter pane opens, then their mined skills render as named rows drawn from the same skillify state the CLI reports, not a bare count. | +| AC-2 | Given a listed skill, when the pane renders it, then a badge shows whether it is local-only (`me` / project) or already shared with the team (`team` / global), derived from real scope/install state. | +| AC-3 | Given a local-only skill, when the developer clicks Promote, then the skill is moved project-to-global on disk via the existing `promote` path and shared at `team` scope on the org table so teammates pull it next session. | +| AC-4 | Given a global skill of the same name already exists, when the developer promotes, then the pane surfaces the refusal and does not overwrite, matching the command's posture. | +| AC-5 | Given a one-click promote, when it completes, then the pane reflects the skill's true resulting state (shared with team, or global-only if the team step was skipped or failed) and never overstates reach. | +| AC-6 | Given a skill has been shared with the team, when the next auto-pull runs, then it flows back into the developer's own Cursor agent via the PRD-005a path bridge, and the pane communicates that loop. | +| AC-7 | Given the developer is not logged in, when they attempt to share a skill with the team, then the pane offers the login path rather than failing silently. | +| AC-8 | Given no locally mined skills exist, when the pane renders, then it shows a coherent empty state with guidance, not a blank or broken pane. | +| AC-9 | Given the pane payload or logs are inspected, when their contents are examined, then no token or API key value appears. | + +--- + +## Open questions + +- [ ] Should one-click Promote always do both steps (project-to-global and team-share), or offer them as two explicit actions, given they touch different stores and a developer may want only local-global promotion (`src/commands/skillify.ts:122-137` versus `src/skillify/skill-org-publish.ts:108-142`)? +- [ ] What is the authoritative source for "is this skill already shared with the team", the local `scope` config (`src/skillify/scope-config.ts`), the presence of a matching row on the org `skills` table, or both, and how does the pane reconcile a disagreement? +- [ ] Should the list include skills mined into the project location of the currently-open Cursor workspace specifically, or all locally mined skills across projects, given the CLI status enumerates per-project state (`src/commands/skillify.ts:74-95`)? +- [ ] For team-sharing, should the promoter set the persistent `scope team` config (affecting all future mining) or share only the selected skill at team scope without changing the global default (`src/skillify/scope-config.ts:70-74`)? +- [ ] How should the pane handle a skill whose team-share republish needs the cross-author scope-promotion path (a skill a teammate also contributed to), so provenance is preserved per the existing rules (`src/skillify/scope-promotion.ts:22-41`, `src/skillify/skill-org-publish.ts:108-119`)? + +--- + +## Related + +- [`prd-005-cursor-skillify-bridge-index`](./prd-005-cursor-skillify-bridge-index.md): parent module. +- [`prd-005a-skillify-bridge`](./prd-005a-skillify-bridge.md): the path bridge that, once a skill is shared, syncs it into the promoter's own (and teammates') Cursor agent on the next pull. +- [`prd-005b-rules-manager`](./prd-005b-rules-manager.md): the sibling write surface in the same "Team" area of the dashboard. +- [`../prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md`](../prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md): the Webview shell this pane lives in, and the "skills created" KPI it complements. +- [`../prd-002-cursor-extension-core/prd-002b-auth-secrets.md`](../prd-002-cursor-extension-core/prd-002b-auth-secrets.md): owns the login state team-sharing depends on. +- Source grounding: `src/commands/skillify.ts:45-137` (status state the list reads, `promoteSkill` project-to-global move and its refusal posture), `src/skillify/scope-config.ts:23-74` (`me | team` scope, `project | global` install, defaults), `src/skillify/scope-promotion.ts:22-41` (the `me` to `team` promotion rule), `src/skillify/skill-org-publish.ts:108-142` (republish to the org table at `team` scope), `src/skillify/local-mined-banner.ts:32-40` (the count-only signal this pane replaces with named rows), `src/cli/skillify-spec.ts:53-56` (the `scope` / `install` / `promote` CLI surface this pane drives). 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 <name> --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 <skill-name>"); 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 `<name>--<author>` 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 `<cwd>/.claude/skills/<name>` *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 `<cwd>/.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 => + '<div class="skill-row" data-dir="' + esc(s.dirName || s.label) + '">' + + '<span>' + esc(s.label) + ' <span class="tag">' + esc(s.scope) + '</span></span>' + +``` + +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 `<input type="text">` 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<void> { + 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] <id> v<n> <author> <text>` (`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/<name>--<author>/` | MET | `agent-roots.ts:69-70`; `pull.ts:584` fans out for global installs. | +| AC-2 | Project pull → `<project>/.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 <text> --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/in-work/README.md b/library/requirements/in-work/README.md new file mode 100644 index 00000000..10319fec --- /dev/null +++ b/library/requirements/in-work/README.md @@ -0,0 +1,19 @@ +--- +ai_description: | + Contains PRD folders actively being implemented. A folder lives here + from the moment implementation begins until the work ships. + Structure inside is identical to backlog/: prd-<###>-<slug>/index + sub-PRDs + qa/. + To promote: move entire prd-<###>-<slug>/ folder to completed/. + Do NOT create new PRD folders here; create them in backlog/ first, + then move to in-work/ when implementation starts. +human_description: | + PRDs currently being implemented. Do not start new PRDs here — + create them in backlog/ and move the folder here when work begins. + When work ships, move the entire folder to completed/. +--- + +# Requirements — In Work + +PRDs currently being implemented. Folder location = lifecycle state. + +Move an entire `prd-<###>-<slug>/` folder **from** `backlog/` → here when implementation starts, and **from** here → `completed/` when the work ships. diff --git a/library/requirements/reports/README.md b/library/requirements/reports/README.md new file mode 100644 index 00000000..64934e76 --- /dev/null +++ b/library/requirements/reports/README.md @@ -0,0 +1,31 @@ +--- +ai_description: | + Contains routine code-scan, QA, and security reports NOT tied to any + specific PRD or IRD. Naming: <YYYY-MM-DD>-<type>-report.md. + Authored by quality-guardian or security-guardian. + Do NOT put per-PRD QA reports here — those go in prd-<###>-<slug>/qa/. + Do NOT put IRD QA reports here — those go in ird-<###>-<slug>/qa/. +human_description: | + Routine scan and audit reports not tied to a specific PRD or IRD. + Examples: weekly security scans, periodic QA sweeps, dependency audits. + Naming: 2026-05-23-security-scan.md, 2026-06-01-qa-sweep.md. + Per-PRD QA reports live inside the PRD folder's qa/ subfolder instead. +--- + +# Requirements — Reports + +Routine code-scan and audit reports not tied to any specific PRD. + +## Naming + +`<YYYY-MM-DD>-<type>-report.md` + +Examples: +- `2026-05-23-security-scan.md` +- `2026-06-01-qa-sweep.md` +- `2026-06-15-dependency-audit.md` + +## What does NOT belong here + +- QA reports for a specific PRD → `requirements/backlog/prd-<###>-<slug>/qa/` +- QA reports for a specific IRD → `issues/backlog/ird-<###>-<slug>/qa/` diff --git a/package-lock.json b/package-lock.json index fcac9a5a..7cd29d04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "@deeplake/hivemind", - "version": "0.7.93", + "version": "0.7.99", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@deeplake/hivemind", - "version": "0.7.93", + "version": "0.7.99", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.97.1", - "@huggingface/transformers": "^3.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "deeplake": "^0.3.30", "js-yaml": "^4.1.1", @@ -38,6 +37,7 @@ "node": ">=22.0.0" }, "optionalDependencies": { + "@huggingface/transformers": "^3.0.0", "tree-sitter": "^0.21.1", "tree-sitter-c": "0.23.2", "tree-sitter-cpp": "^0.23.4", @@ -1610,6 +1610,7 @@ "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.8.tgz", "integrity": "sha512-ZdElB7DPS7QQS8ZnFc5RPPtkg+eN11z8AmIZWAyes6pSbwXqiFB/POVevvm01begdSX1ho9Gxln/F6qlQMsuaA==", "license": "MIT", + "optional": true, "engines": { "node": ">=18" } @@ -1619,6 +1620,7 @@ "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", "license": "Apache-2.0", + "optional": true, "dependencies": { "@huggingface/jinja": "^0.5.3", "onnxruntime-node": "1.21.0", @@ -1631,6 +1633,7 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", + "optional": true, "engines": { "node": ">=18" } @@ -2132,6 +2135,7 @@ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "license": "ISC", + "optional": true, "dependencies": { "minipass": "^7.0.4" }, @@ -2406,31 +2410,36 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/codegen": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/fetch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", "license": "BSD-3-Clause", + "optional": true, "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" @@ -2440,31 +2449,36 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/inquire": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/utf8": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.13", @@ -3593,6 +3607,7 @@ "version": "25.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -4013,7 +4028,8 @@ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/bowser": { "version": "2.14.1", @@ -4435,6 +4451,7 @@ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", + "optional": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -4452,6 +4469,7 @@ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "license": "MIT", + "optional": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -4477,6 +4495,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -4486,7 +4505,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/diff": { "version": "8.0.4", @@ -4604,7 +4624,8 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/esbuild": { "version": "0.28.0", @@ -4659,6 +4680,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", + "optional": true, "engines": { "node": ">=10" }, @@ -5005,7 +5027,8 @@ "version": "25.9.23", "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "optional": true }, "node_modules/forwarded": { "version": "0.2.0", @@ -5175,6 +5198,7 @@ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", "license": "BSD-3-Clause", + "optional": true, "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", @@ -5192,6 +5216,7 @@ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "license": "MIT", + "optional": true, "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" @@ -5232,7 +5257,8 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/has-flag": { "version": "4.0.0", @@ -5249,6 +5275,7 @@ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", + "optional": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -5694,7 +5721,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/jsonfile": { "version": "6.2.1", @@ -6111,7 +6139,8 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "optional": true }, "node_modules/magic-string": { "version": "0.30.21", @@ -6170,6 +6199,7 @@ "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", "license": "MIT", + "optional": true, "dependencies": { "escape-string-regexp": "^4.0.0" }, @@ -6342,6 +6372,7 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "license": "BlueOak-1.0.0", + "optional": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -6351,6 +6382,7 @@ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", + "optional": true, "dependencies": { "minipass": "^7.1.2" }, @@ -6525,6 +6557,7 @@ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "license": "MIT", + "optional": true, "engines": { "node": ">= 0.4" } @@ -6581,7 +6614,8 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/onnxruntime-node": { "version": "1.21.0", @@ -6589,6 +6623,7 @@ "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", "hasInstallScript": true, "license": "MIT", + "optional": true, "os": [ "win32", "darwin", @@ -6605,6 +6640,7 @@ "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", "license": "MIT", + "optional": true, "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", @@ -6618,7 +6654,8 @@ "version": "1.22.0-dev.20250409-89f8206ba4", "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/papaparse": { "version": "5.5.3", @@ -6812,7 +6849,8 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/postcss": { "version": "8.5.8", @@ -6930,6 +6968,7 @@ "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", "hasInstallScript": true, "license": "BSD-3-Clause", + "optional": true, "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", @@ -7335,6 +7374,7 @@ "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", "license": "BSD-3-Clause", + "optional": true, "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", @@ -7458,6 +7498,7 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7470,7 +7511,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/send": { "version": "1.2.1", @@ -7503,6 +7545,7 @@ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", "license": "MIT", + "optional": true, "dependencies": { "type-fest": "^0.13.1" }, @@ -7544,6 +7587,7 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", + "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -7975,6 +8019,7 @@ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", "license": "BlueOak-1.0.0", + "optional": true, "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -8021,6 +8066,7 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "license": "BlueOak-1.0.0", + "optional": true, "engines": { "node": ">=18" } @@ -8858,6 +8904,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", "license": "(MIT OR CC0-1.0)", + "optional": true, "engines": { "node": ">=10" }, @@ -8909,6 +8956,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -9229,6 +9277,7 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "license": "BlueOak-1.0.0", + "optional": true, "engines": { "node": ">=18" } diff --git a/package.json b/package.json index fd519a19..78b7dd37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deeplake/hivemind", - "version": "0.7.93", + "version": "0.7.99", "description": "Cloud-backed persistent shared memory for AI agents powered by Deeplake", "type": "module", "repository": { @@ -15,16 +15,16 @@ }, "files": [ "bundle", - "codex/bundle", - "codex/skills", - "cursor/bundle", - "hermes/bundle", + "harnesses/codex/bundle", + "harnesses/codex/skills", + "harnesses/cursor/bundle", + "harnesses/hermes/bundle", "mcp/bundle", - "pi/extension-source", - "openclaw/dist", - "openclaw/skills", - "openclaw/openclaw.plugin.json", - "openclaw/package.json", + "harnesses/pi/extension-source", + "harnesses/openclaw/dist", + "harnesses/openclaw/skills", + "harnesses/openclaw/openclaw.plugin.json", + "harnesses/openclaw/package.json", ".claude-plugin", "scripts", "README.md", diff --git a/scripts/audit-openclaw-bundle.mjs b/scripts/audit-openclaw-bundle.mjs index 9a2a793b..f3a0a3f0 100644 --- a/scripts/audit-openclaw-bundle.mjs +++ b/scripts/audit-openclaw-bundle.mjs @@ -11,7 +11,7 @@ * Reference: openclaw repo, src/security/skill-scanner.ts:147-206 * * Usage: - * node scripts/audit-openclaw-bundle.mjs # scan openclaw/dist + * node scripts/audit-openclaw-bundle.mjs # scan harnesses/openclaw/dist * node scripts/audit-openclaw-bundle.mjs <path> # scan a specific dir * * Exits non-zero if any "critical" or "warn" finding is reported. @@ -32,7 +32,7 @@ import { join, extname } from "node:path"; // surface every drift before it ships. const rawArgs = process.argv.slice(2); const STRICT_MODE = !rawArgs.includes("--criticals-only"); -const SCAN_DIR = rawArgs.find(a => !a.startsWith("--")) ?? "openclaw/dist"; +const SCAN_DIR = rawArgs.find(a => !a.startsWith("--")) ?? "harnesses/openclaw/dist"; const SCANNABLE_EXT = new Set([".js", ".mjs", ".cjs"]); const MAX_FILE_BYTES = 1024 * 1024; // 1MB; matches upstream default diff --git a/scripts/sync-versions.mjs b/scripts/sync-versions.mjs index 4e243cc8..e2d7f194 100644 --- a/scripts/sync-versions.mjs +++ b/scripts/sync-versions.mjs @@ -15,10 +15,10 @@ const SOURCE = "package.json"; // Scalar targets: each has a single top-level `version` field tracking package.json. export const SCALAR_TARGETS = [ ".claude-plugin/plugin.json", - "claude-code/.claude-plugin/plugin.json", - "openclaw/openclaw.plugin.json", - "openclaw/package.json", - "codex/package.json", + "harnesses/claude-code/.claude-plugin/plugin.json", + "harnesses/openclaw/openclaw.plugin.json", + "harnesses/openclaw/package.json", + "harnesses/codex/package.json", ]; // Marketplace target: has BOTH metadata.version AND every plugins[].version. diff --git a/hermes/skills/hivemind-goals/SKILL.md b/skills/hivemind-goals/SKILL.md similarity index 100% rename from hermes/skills/hivemind-goals/SKILL.md rename to skills/hivemind-goals/SKILL.md diff --git a/hermes/skills/hivemind-graph/SKILL.md b/skills/hivemind-graph/SKILL.md similarity index 100% rename from hermes/skills/hivemind-graph/SKILL.md rename to skills/hivemind-graph/SKILL.md diff --git a/src/cli/embeddings.ts b/src/cli/embeddings.ts index 9f1ed3f6..e1e78dd7 100644 --- a/src/cli/embeddings.ts +++ b/src/cli/embeddings.ts @@ -54,8 +54,8 @@ export function findHivemindInstalls(home: string = HOME): AgentInstall[] { const dir = join(ccCache, ver); try { if (!statSync(dir).isDirectory()) continue; } catch { continue; } // Bundle layout differs slightly: marketplace installs put it directly - // under <ver>/bundle, while local-clone-style layouts use <ver>/claude-code/bundle. - const candidates = [join(dir, "bundle"), join(dir, "claude-code", "bundle")]; + // under <ver>/bundle, while local-clone-style layouts use <ver>/harnesses/claude-code/bundle. + const candidates = [join(dir, "bundle"), join(dir, "harnesses", "claude-code", "bundle")]; if (candidates.some(p => existsSync(p))) { out.push({ id: `claude (${ver})`, pluginDir: dir }); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 546e3137..31b6430c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -138,7 +138,7 @@ Team-wide rules: Cross-agent helpers: hivemind context Print the rules + open-goals block on demand. - Fallback for pi/openclaw agents (no SessionStart hook) + Fallback for harnesses/pi/openclaw agents (no SessionStart hook) and read-only diagnostic for any agent. Account / org / workspace: diff --git a/src/cli/install-codex.ts b/src/cli/install-codex.ts index 8b93d073..99b571fe 100644 --- a/src/cli/install-codex.ts +++ b/src/cli/install-codex.ts @@ -31,7 +31,7 @@ function buildHooksJson(): Record<string, unknown> { PostToolUse: [hookCmd("capture.js", 15)], // One Stop matcher-block with TWO commands — stop.js (capture) + // graph-on-stop.js (code-graph auto-build, G3). Single block (not two) - // mirrors the static codex/hooks/hooks.json and keeps one entry per + // mirrors the static harnesses/codex/hooks/hooks.json and keeps one entry per // event for the merge/dedupe logic. Stop: [stopBlockWithGraph(30)], }, @@ -228,8 +228,8 @@ function stripLegacyCodexHooksKey(): void { } export function installCodex(): void { - const srcBundle = join(pkgRoot(), "codex", "bundle"); - const srcSkills = join(pkgRoot(), "codex", "skills"); + const srcBundle = join(pkgRoot(), "harnesses", "codex", "bundle"); + const srcSkills = join(pkgRoot(), "harnesses", "codex", "skills"); if (!existsSync(srcBundle)) { throw new Error(`Codex bundle missing at ${srcBundle}. Run 'npm run build' first.`); 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<string, unknown> | 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/cli/install-hermes.ts b/src/cli/install-hermes.ts index 15a624f9..3ca5545e 100644 --- a/src/cli/install-hermes.ts +++ b/src/cli/install-hermes.ts @@ -183,7 +183,7 @@ export function installHermes(): void { log(` Hermes skill installed -> ${SKILLS_DIR}`); // 2. Hook bundle — auto-capture via Hermes shell-hooks. - const srcBundle = join(pkgRoot(), "hermes", "bundle"); + const srcBundle = join(pkgRoot(), "harnesses", "hermes", "bundle"); if (!existsSync(srcBundle)) { throw new Error(`Hermes bundle missing at ${srcBundle}. Run 'npm run build' first.`); } diff --git a/src/cli/install-openclaw.ts b/src/cli/install-openclaw.ts index 7b5f8058..559936c4 100644 --- a/src/cli/install-openclaw.ts +++ b/src/cli/install-openclaw.ts @@ -2,15 +2,15 @@ import { existsSync, copyFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { HOME, pkgRoot, ensureDir, copyDir, writeVersionStamp, log, warn } from "./util.js"; import { getVersion } from "./version.js"; -import { ensureHivemindAllowlisted } from "../../openclaw/src/setup-config.js"; +import { ensureHivemindAllowlisted } from "../../harnesses/openclaw/src/setup-config.js"; const PLUGIN_DIR = join(HOME, ".openclaw", "extensions", "hivemind"); export function installOpenclaw(): void { - const srcDist = join(pkgRoot(), "openclaw", "dist"); - const srcManifest = join(pkgRoot(), "openclaw", "openclaw.plugin.json"); - const srcPkg = join(pkgRoot(), "openclaw", "package.json"); - const srcSkills = join(pkgRoot(), "openclaw", "skills"); + const srcDist = join(pkgRoot(), "harnesses", "openclaw", "dist"); + const srcManifest = join(pkgRoot(), "harnesses", "openclaw", "openclaw.plugin.json"); + const srcPkg = join(pkgRoot(), "harnesses", "openclaw", "package.json"); + const srcSkills = join(pkgRoot(), "harnesses", "openclaw", "skills"); if (!existsSync(srcDist)) { throw new Error(`OpenClaw bundle missing at ${srcDist}. Run 'npm run build' first.`); diff --git a/src/cli/install-pi.ts b/src/cli/install-pi.ts index b344ea7f..2092cd04 100644 --- a/src/cli/install-pi.ts +++ b/src/cli/install-pi.ts @@ -120,7 +120,7 @@ export function installPi(): void { writeFileSync(AGENTS_MD, next); // 2. Extension — autocapture + first-class hivemind tools. - const srcExtension = join(pkgRoot(), "pi", "extension-source", "hivemind.ts"); + const srcExtension = join(pkgRoot(), "harnesses", "pi", "extension-source", "hivemind.ts"); if (!existsSync(srcExtension)) { throw new Error(`pi extension source missing at ${srcExtension}. Reinstall the @deeplake/hivemind package.`); } @@ -129,7 +129,7 @@ export function installPi(): void { // 3. Wiki-worker bundle (spawned by extension at periodic + session_shutdown // triggers to generate AI summary via `pi --print`). - const srcWorker = join(pkgRoot(), "pi", "bundle", "wiki-worker.js"); + const srcWorker = join(pkgRoot(), "harnesses", "pi", "bundle", "wiki-worker.js"); if (existsSync(srcWorker)) { ensureDir(WIKI_WORKER_DIR); copyFileSync(srcWorker, WIKI_WORKER_PATH); @@ -138,7 +138,7 @@ export function installPi(): void { // 4. Skillify-worker bundle (spawned by extension on session_shutdown to // mine reusable skills from the finished session). Same dir as // wiki-worker, same shared ensureDir. - const srcSkillifyWorker = join(pkgRoot(), "pi", "bundle", "skillify-worker.js"); + const srcSkillifyWorker = join(pkgRoot(), "harnesses", "pi", "bundle", "skillify-worker.js"); if (existsSync(srcSkillifyWorker)) { ensureDir(WIKI_WORKER_DIR); copyFileSync(srcSkillifyWorker, SKILLIFY_WORKER_PATH); @@ -146,7 +146,7 @@ export function installPi(): void { // 5. Autopull-worker bundle (spawned synchronously by extension on // session_start to pull all-author skills from the org). Same dir. - const srcAutopullWorker = join(pkgRoot(), "pi", "bundle", "autopull-worker.js"); + const srcAutopullWorker = join(pkgRoot(), "harnesses", "pi", "bundle", "autopull-worker.js"); if (existsSync(srcAutopullWorker)) { ensureDir(WIKI_WORKER_DIR); copyFileSync(srcAutopullWorker, AUTOPULL_WORKER_PATH); @@ -154,7 +154,7 @@ export function installPi(): void { // 6. SkillOpt-worker bundle (spawned by extension on a user reaction to judge + // improve a recently-used org skill). Same dir, same cleanup. - const srcSkilloptWorker = join(pkgRoot(), "pi", "bundle", "skillopt-worker.js"); + const srcSkilloptWorker = join(pkgRoot(), "harnesses", "pi", "bundle", "skillopt-worker.js"); if (existsSync(srcSkilloptWorker)) { ensureDir(WIKI_WORKER_DIR); copyFileSync(srcSkilloptWorker, SKILLOPT_WORKER_PATH); diff --git a/src/cli/skillify-spec.ts b/src/cli/skillify-spec.ts index 7acd676f..41538f32 100644 --- a/src/cli/skillify-spec.ts +++ b/src/cli/skillify-spec.ts @@ -5,7 +5,7 @@ * * 1. `SKILLIFY_COMMANDS` — flat one-line-per-entry. Consumed by the four * per-agent SessionStart inject blocks (claude-code/codex/cursor/hermes), - * the pi mirror in `pi/extension-source/hivemind.ts`, and the bundle-scan + * the pi mirror in `harnesses/pi/extension-source/hivemind.ts`, and the bundle-scan * tests that assert specific subcommand strings appear verbatim in the * shipped JS. Kept as a literal array (not derived) so esbuild preserves * every entry as a string literal in the bundle. diff --git a/src/cli/util.ts b/src/cli/util.ts index aca6168c..edc71fc8 100644 --- a/src/cli/util.ts +++ b/src/cli/util.ts @@ -14,7 +14,7 @@ export const HOME = homedir(); // → install dir // Without the walk-up, the source path resolved to `src/` (one dir up // from src/cli/util.ts), so unit tests importing the installers couldn't -// find the per-agent bundles at project_root/<agent>/bundle/. +// find the per-agent bundles at project_root/harnesses/<agent>/bundle/. export function pkgRoot(): string { let dir = fileURLToPath(new URL(".", import.meta.url)); for (let i = 0; i < 8; i++) { 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}/<name>/SKILL.md.`); } -function promoteSkill(name: string, cwd: string): void { - if (!name) { console.error("Usage: hivemind skillify promote <skill-name>"); 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<number> { + 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<Record<string, unknown>[]>; + + 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<void> { + const work = [...args]; + const scopeRaw = takeFlagValue(work, "--scope"); + const shareTeam = scopeRaw === "team"; + const name = work[0] ?? ""; + if (!name) { console.error("Usage: hivemind skillify promote <skill-name> [--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 <skill-name> [--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<DashboardKpis> { 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<ReturnType<typeof fetchOrgStatsWithMeta>>["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<DashboardKpis> { 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<DashboardKpis> { 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<DashboardKpis> { memorySearches: 0, sessionsCount: null, userTokensSaved: null, + orgStatsFetchedAt: null, + orgStatsStale: false, + orgStatsOffline: false, }; } diff --git a/src/graph/build-lock.ts b/src/graph/build-lock.ts index 3cba38c9..87eb8bbd 100644 --- a/src/graph/build-lock.ts +++ b/src/graph/build-lock.ts @@ -119,7 +119,7 @@ export function acquireBuildLock(baseDir: string): LockResult { * will fall through to the stale-recovery path after STALE_LOCK_MS. */ export function releaseBuildLock(baseDir: string): void { - // Owner-gated release (codex/CodeRabbit P1): if stale-recovery happened + // Owner-gated release (harnesses/codex/CodeRabbit P1): if stale-recovery happened // while an older build was still running, the older process must NOT // unlink the NEWER process's lock when it eventually exits. Read the // lock's `pid` field and only unlink when it matches ours. diff --git a/src/hooks/cursor/pre-tool-use.ts b/src/hooks/cursor/pre-tool-use.ts index 2fc5eeca..9bfd235b 100644 --- a/src/hooks/cursor/pre-tool-use.ts +++ b/src/hooks/cursor/pre-tool-use.ts @@ -26,6 +26,7 @@ * command alone for Cursor's own bash to run. */ +import { basename } from "node:path"; import { readStdin } from "../../utils/stdin.js"; import { loadConfig } from "../../config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; @@ -33,6 +34,7 @@ import { log as _log } from "../../utils/debug.js"; import { parseBashGrep, handleGrepDirect } from "../grep-direct.js"; import { touchesMemory, rewritePaths } from "../memory-path-utils.js"; import { tryGraphRead } from "../../graph/graph-command.js"; +import { recordRecall } from "../../notifications/recall-tracker.js"; const log = (msg: string) => _log("cursor-pre-tool-use", msg); interface CursorShellToolInput { @@ -103,6 +105,13 @@ async function main(): Promise<void> { return; } log(`intercepted ${command.slice(0, 80)} → ${result.length} chars from SQL fast-path`); + // Record the recall (count + bytes delivered) for the dashboard's + // memory-search and tokens-saved KPIs. Fail-soft inside recordRecall. + recordRecall({ + sessionId: input.conversation_id, + bytes: Buffer.byteLength(result, "utf-8"), + project: input.cwd ? basename(input.cwd) : null, + }); // Replace the original Shell command with `echo <result>` so Cursor's // own bash runs a no-op-ish command and the agent sees our SQL answer. const echoCmd = `cat <<'__HIVEMIND_RESULT__'\n${result}\n__HIVEMIND_RESULT__`; diff --git a/src/hooks/cursor/session-end.ts b/src/hooks/cursor/session-end.ts index 7b8a4048..924a5999 100644 --- a/src/hooks/cursor/session-end.ts +++ b/src/hooks/cursor/session-end.ts @@ -7,7 +7,7 @@ * final_status, error_message? } + common payload. * * Spawns a final wiki-worker run via cursor-agent --print so the session - * gets an AI summary in the memory table. Mirrors the codex/CC flow. + * gets an AI summary in the memory table. Mirrors the harnesses/codex/CC flow. */ import { readStdin } from "../../utils/stdin.js"; diff --git a/src/hooks/graph-on-stop.ts b/src/hooks/graph-on-stop.ts index 97f26a85..a689eaa1 100644 --- a/src/hooks/graph-on-stop.ts +++ b/src/hooks/graph-on-stop.ts @@ -1,7 +1,7 @@ /** * Auto-build hook for the codebase-graph feature (Phase 1.5). * - * Registered in claude-code/hooks/hooks.json under BOTH "Stop" AND + * Registered in harnesses/claude-code/hooks/hooks.json under BOTH "Stop" AND * "SessionEnd" with async: true. Why both: * - "Stop" fires after every model turn in INTERACTIVE Claude sessions. * Rate-limit gate (10 min default) keeps the per-turn cost ~5ms in diff --git a/src/hooks/hermes/session-end.ts b/src/hooks/hermes/session-end.ts index 295d44d8..a0b31454 100644 --- a/src/hooks/hermes/session-end.ts +++ b/src/hooks/hermes/session-end.ts @@ -2,7 +2,7 @@ * Hermes on_session_end hook (fire-and-forget). * * Spawns a final wiki-worker run via `hermes -z` so the session gets - * an AI summary in the memory table. Mirrors the codex/CC flow. + * an AI summary in the memory table. Mirrors the harnesses/codex/CC flow. */ import { readStdin } from "../../utils/stdin.js"; diff --git a/src/hooks/hermes/session-start.ts b/src/hooks/hermes/session-start.ts index 3d1038e2..ae25810e 100644 --- a/src/hooks/hermes/session-start.ts +++ b/src/hooks/hermes/session-start.ts @@ -206,7 +206,7 @@ async function main(): Promise<void> { // `hivemind goal add/list/...` via terminal. End state in tables // is identical to the VFS-routed path. const baseWithGoals = creds?.token ? `${baseContext}\n\n${GOALS_INSTRUCTIONS_CLI}` : baseContext; - // Code-graph inject. Unlike claude-code/cursor this is user-visible in the + // Code-graph inject. Unlike harnesses/claude-code/cursor this is user-visible in the // Hermes TUI (Hermes has no model-only SessionStart channel), but an // always-present structural index is worth the extra lines. graphContextLine // returns null — and appends nothing — when no graph exists for this repo yet. diff --git a/src/hooks/pi/wiki-worker.ts b/src/hooks/pi/wiki-worker.ts index 5ce4b7a2..51af6da3 100644 --- a/src/hooks/pi/wiki-worker.ts +++ b/src/hooks/pi/wiki-worker.ts @@ -5,7 +5,7 @@ * runs `pi --print` to generate a wiki summary, and uploads it to the * memory table. * - * Invoked by pi/extension-source/hivemind.ts (periodic + session_shutdown + * Invoked by harnesses/pi/extension-source/hivemind.ts (periodic + session_shutdown * triggers) as: node wiki-worker.js <config.json>. The extension itself * is shipped as raw .ts and can't import this file directly — that's why * this lives as a standalone bundle deposited next to the extension at diff --git a/src/hooks/session-notifications.ts b/src/hooks/session-notifications.ts index 6062c807..f11746e7 100644 --- a/src/hooks/session-notifications.ts +++ b/src/hooks/session-notifications.ts @@ -3,7 +3,7 @@ /** * Claude Code SessionStart hook entry point — notifications channel. * - * Wired as a SECOND SessionStart hook command in claude-code/hooks/hooks.json, + * Wired as a SECOND SessionStart hook command in harnesses/claude-code/hooks/hooks.json, * alongside the existing memory/hivemind hook (session-start.js). * * Bundle target: bundle/session-notifications.js. See esbuild.config.mjs. diff --git a/src/hooks/shared/context-renderer.ts b/src/hooks/shared/context-renderer.ts index 77f32aaa..13dafdd7 100644 --- a/src/hooks/shared/context-renderer.ts +++ b/src/hooks/shared/context-renderer.ts @@ -16,7 +16,7 @@ * - Per-agent forks differ only in how they wrap the surrounding * context (stdin shape, output envelope, agent-specific log lines). * The rules / goals rendering is invariant. - * - `hivemind context` CLI for pi/openclaw calls the same renderer + * - `hivemind context` CLI for harnesses/pi/openclaw calls the same renderer * to print the block on demand — same output as SessionStart, * deterministically. * diff --git a/src/hooks/shared/skillopt-hook.ts b/src/hooks/shared/skillopt-hook.ts index 4ef46ee3..e083517b 100644 --- a/src/hooks/shared/skillopt-hook.ts +++ b/src/hooks/shared/skillopt-hook.ts @@ -11,7 +11,7 @@ import { SKILLOPT_ENV } from "../../skillify/skillopt-env.js"; /** * Recover an org-skill ref from a tool call that LOADS a skill's SKILL.md — how agents without * a first-class `Skill` tool use skills: pi `read`s `.../skills/<dir>/SKILL.md` (structured - * `path`), codex/hermes SHELL a read of it (path inside `command`). The `<dir>` segment is the + * `path`), harnesses/codex/hermes SHELL a read of it (path inside `command`). The `<dir>` segment is the * ref. Returns null when it isn't a SKILL.md load. markSkillPending still gates the ref * (org-shape + manifest), so a bare/non-org dir is rejected there. */ diff --git a/src/notifications/recall-tracker.ts b/src/notifications/recall-tracker.ts new file mode 100644 index 00000000..cbdf65e9 --- /dev/null +++ b/src/notifications/recall-tracker.ts @@ -0,0 +1,56 @@ +/** + * Recall-event tracker. + * + * Records one event per memory recall (a grep/search against the + * `~/.deeplake/memory/` virtual filesystem that returned content) to + * `~/.deeplake/recall-events.jsonl`. The Cursor pre-tool-use hook calls + * `recordRecall` at the exact moment it serves recalled bytes to the agent, + * which is the only place the byte count is known. + * + * Why a dedicated store (not usage-stats.jsonl): usage-stats.jsonl is a + * one-record-per-session stream written at SessionEnd by the Claude Code + * transcript parser. Cursor never populates it (different transcript shape), + * which is why the dashboard's memory-search and tokens-saved KPIs read 0. + * recall-events.jsonl is append-per-recall and read directly by the Cursor + * extension dashboard. + * + * Every operation is fail-soft: recall tracking must never break the hook + * that delivers memory to the agent. + */ + +import { appendFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export interface RecallEvent { + /** ISO timestamp of the recall. */ + ts: string; + /** Session / conversation id, when the hook can supply one. */ + sessionId: string; + /** Byte length of the content served back to the agent. */ + bytes: number; + /** Repo project name (basename of cwd), when known. */ + project: string | null; +} + +export function recallEventsPath(): string { + return join(homedir(), ".deeplake", "recall-events.jsonl"); +} + +/** Append a single recall event. Never throws. No-op for empty results. */ +export function recordRecall(ev: { sessionId?: string; bytes: number; project?: string | null }): void { + try { + if (!Number.isFinite(ev.bytes) || ev.bytes <= 0) return; + const record: RecallEvent = { + ts: new Date().toISOString(), + sessionId: ev.sessionId && ev.sessionId.length > 0 ? ev.sessionId : "unknown", + bytes: Math.round(ev.bytes), + project: ev.project ?? null, + }; + const path = recallEventsPath(); + mkdirSync(dirname(path), { recursive: true }); + appendFileSync(path, JSON.stringify(record) + "\n", "utf-8"); + } catch { + /* fail-soft: recall tracking must never break the recall hook */ + } +} 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<OrgStats | null> { - 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<OrgStatsFetchResult> { + 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<OrgStats }); if (!resp.ok) { log(`fetch ${url} returned ${resp.status}`); - return stale ?? null; + if (stale && cacheFetchedAt != null) { + return { + stats: stale, + meta: metaFromCache(cacheFetchedAt, true, true), + }; + } + return { stats: null, meta: EMPTY_META }; } const body = (await resp.json()) as ServerResponse; if (!body || typeof body !== "object") { log(`fetch ${url} returned malformed body`); - return stale ?? null; + if (stale && cacheFetchedAt != null) { + return { + stats: stale, + meta: metaFromCache(cacheFetchedAt, true, true), + }; + } + return { stats: null, meta: EMPTY_META }; } const data: OrgStats = { org: scopeFromServer(body.org), @@ -198,10 +266,24 @@ export async function fetchOrgStats(creds: Credentials | null): Promise<OrgStats }; writeCache(scopeKey, data); log(`fetched org stats from ${apiUrl}`); - return data; + return { + stats: data, + meta: { + fetchedAt: new Date().toISOString(), + stale: false, + offline: false, + fromCache: false, + }, + }; } catch (e: any) { log(`fetch ${url} failed: ${e?.message ?? String(e)}`); - return stale ?? null; + if (stale && cacheFetchedAt != null) { + return { + stats: stale, + meta: metaFromCache(cacheFetchedAt, true, true), + }; + } + return { stats: null, meta: EMPTY_META }; } finally { clearTimeout(timeoutHandle); } diff --git a/src/skillify/agent-model.ts b/src/skillify/agent-model.ts index 3b3102d5..ac6209d1 100644 --- a/src/skillify/agent-model.ts +++ b/src/skillify/agent-model.ts @@ -105,7 +105,7 @@ function envModel(agent: Agent, role: ScorerRole, env: NodeJS.ProcessEnv): strin return env[specific] ?? env[fallback]; } -/** Per-agent provider override (hermes/pi): HIVEMIND_SKILLOPT_<AGENT>_PROVIDER. The +/** Per-agent provider override (harnesses/hermes/pi): HIVEMIND_SKILLOPT_<AGENT>_PROVIDER. The * openrouter default is wrong for a user on a different provider (e.g. AWS Bedrock), * so the provider must be overridable per install. */ function envProvider(agent: Agent, env: NodeJS.ProcessEnv): string | undefined { @@ -132,7 +132,7 @@ export function agentModel(opts: { const spawnFn = opts.spawnImpl ?? (nodeSpawn as unknown as SpawnFn); const bin = opts.bin ?? findAgentBin(opts.agent); return (system, user) => new Promise<string>((resolve, reject) => { - // Fail fast on a provider override without a matching model (hermes/pi): the default + // Fail fast on a provider override without a matching model (harnesses/hermes/pi): the default // model is provider-specific (openrouter-style ids), so a bare ..._PROVIDER=bedrock // with no ..._MODEL would silently send a wrong model id. Surface it loudly. if (providerOverride && !modelOverride && (opts.agent === "hermes" || opts.agent === "pi")) { diff --git a/src/skillify/agent-roots.ts b/src/skillify/agent-roots.ts index e1efc0ed..b4afc05b 100644 --- a/src/skillify/agent-roots.ts +++ b/src/skillify/agent-roots.ts @@ -24,8 +24,10 @@ * canonical write location IS that path; symlinking a skill into itself * would be a no-op at best and a self-referential loop at worst. * - * Cursor has no native skill discovery (only hooks/rules), so it is not - * a candidate. + * Cursor discovers skills under `~/.cursor/skills-cursor/` (global) and + * `<project>/.cursor/skills/` (project). When `~/.cursor` exists, the + * global Cursor root is included; when `projectRoot` is passed, the + * project Cursor root is included as well. */ import { existsSync } from "node:fs"; @@ -45,11 +47,12 @@ import { join } from "node:path"; * `fanOutSymlinks` upstream calls `mkdirSync(dirname(link), { recursive })` * before each symlink, so the directory is created on first fan-out. */ -function resolveDetected(home: string): string[] { +function resolveDetected(home: string, projectRoot?: string): string[] { 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")); // agentskills.io shared root — codex creates it, pi co-consumes it. if (codexInstalled || piInstalled) { @@ -63,6 +66,12 @@ function resolveDetected(home: string): string[] { 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; } @@ -79,6 +88,7 @@ function resolveDetected(home: string): string[] { export function detectAgentSkillsRoots( canonicalRoot: string, home: string = homedir(), + projectRoot?: string, ): string[] { - return resolveDetected(home).filter(p => p !== canonicalRoot); + return resolveDetected(home, projectRoot).filter((p) => p !== canonicalRoot); } diff --git a/src/skillify/autopull-worker.ts b/src/skillify/autopull-worker.ts index 7f464a93..e2d47159 100644 --- a/src/skillify/autopull-worker.ts +++ b/src/skillify/autopull-worker.ts @@ -2,7 +2,7 @@ * Standalone entrypoint that runs `autoPullSkills()` once and exits. * Bundled by esbuild for agents that can't import the shared module * (currently just pi, which ships its extension as raw .ts with zero - * non-builtin runtime dependencies — see pi/extension-source/hivemind.ts). + * non-builtin runtime dependencies — see harnesses/pi/extension-source/hivemind.ts). * * Pi spawns this synchronously from session_start and waits for it to * exit before assembling the additionalContext payload. That mirrors diff --git a/src/skillify/gate-runner.ts b/src/skillify/gate-runner.ts index f4029609..1dba99eb 100644 --- a/src/skillify/gate-runner.ts +++ b/src/skillify/gate-runner.ts @@ -23,8 +23,8 @@ import { createRequire } from "node:module"; // the gate prompt, but the literal symbol name `execFileSync` paired with // a `child_process` import would trip ClawHub's per-bundle static scanner // (`dangerous-exec`) when this module is bundled into -// `openclaw/dist/skillify-worker.js`. Mirrors the same `createRequire`- -// based bypass used by `openclaw/src/index.ts:78-80` for `spawn`. The +// `harnesses/openclaw/dist/skillify-worker.js`. Mirrors the same `createRequire`- +// based bypass used by `harnesses/openclaw/src/index.ts:78-80` for `spawn`. The // scanner's regex `\bexecFileSync\s*\(` doesn't match the renamed // identifier, and esbuild can't statically intercept `require()` returned // from `createRequire`. @@ -76,7 +76,7 @@ export interface GateRunResult { * install locations, in priority order, until one exists on disk. * * Why no `which` / no PATH walk: this module is bundled into the openclaw - * skillify-worker (`openclaw/dist/skillify-worker.js`), which ClawHub + * skillify-worker (`harnesses/openclaw/dist/skillify-worker.js`), which ClawHub * scans per-file at publish time. Both `child_process.execFileSync` * (`dangerous-exec`) and `process.env.PATH` reads (`env-harvesting`) * trip critical rules because the worker also `fetch()`-es Deeplake. So diff --git a/src/skillify/pull.ts b/src/skillify/pull.ts index f6251d82..67e920bc 100644 --- a/src/skillify/pull.ts +++ b/src/skillify/pull.ts @@ -283,7 +283,7 @@ export function backfillSymlinks(installRoot: string): void { const manifest = loadManifest(); const entries = entriesForRoot(manifest, "global", installRoot); if (entries.length === 0) return; - const detected = detectAgentSkillsRoots(installRoot); + const detected = detectAgentSkillsRoots(installRoot, homedir()); for (const entry of entries) { const canonical = join(entry.installRoot, entry.dirName); if (!existsSync(canonical)) continue; // pruned/orphan, leave alone @@ -581,7 +581,7 @@ export async function runPull(opts: PullOptions): Promise<PullSummary> { // <cwd>/.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 `--<author>` dirname heuristic diff --git a/src/skillify/skill-invocations.ts b/src/skillify/skill-invocations.ts index e1430951..289a332b 100644 --- a/src/skillify/skill-invocations.ts +++ b/src/skillify/skill-invocations.ts @@ -46,7 +46,7 @@ export function parseMessage(m: unknown): ParsedMsg | null { /** Match a path that loads a skill's SKILL.md anywhere in `s` → the `<dir>` ref (name--author), * else null. Works on a bare path (pi `read` tool_input.path) or inside a shell command string - * (codex/hermes `cat …/SKILL.md`). The dir class excludes whitespace/quotes so a command's + * (harnesses/codex/hermes `cat …/SKILL.md`). The dir class excludes whitespace/quotes so a command's * trailing args don't get swallowed into the ref. */ export function pathToSkillRef(s: unknown): string | null { if (typeof s !== "string") return null; diff --git a/src/skillify/skillopt-worker.ts b/src/skillify/skillopt-worker.ts index 7f84f515..9959178e 100644 --- a/src/skillify/skillopt-worker.ts +++ b/src/skillify/skillopt-worker.ts @@ -62,7 +62,7 @@ async function main(): Promise<void> { const now = new Date().toISOString(); // Score on the USER's own agent (cost lands on them), not hardcoded claude — a - // codex/hermes/cursor/pi user with no local `claude` still gets SkillOpt. The + // harnesses/codex/hermes/cursor/pi user with no local `claude` still gets SkillOpt. The // judge/proposer run no-tools (untrusted reaction/transcript text in the prompt). const agent = detectScorerAgent(); const agentBin = resolveAgentBin(agent); 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> = {}): 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/embeddings-bundle-scan.test.ts b/tests/claude-code/embeddings-bundle-scan.test.ts index 22020977..27ea58ab 100644 --- a/tests/claude-code/embeddings-bundle-scan.test.ts +++ b/tests/claude-code/embeddings-bundle-scan.test.ts @@ -24,13 +24,13 @@ interface AgentBundle { const AGENTS: AgentBundle[] = [ { agent: "claude-code", - embedDaemon: join(repoRoot, "claude-code", "bundle", "embeddings", "embed-daemon.js"), - captureHook: join(repoRoot, "claude-code", "bundle", "capture.js"), + embedDaemon: join(repoRoot, "harnesses", "claude-code", "bundle", "embeddings", "embed-daemon.js"), + captureHook: join(repoRoot, "harnesses", "claude-code", "bundle", "capture.js"), }, { agent: "codex", - embedDaemon: join(repoRoot, "codex", "bundle", "embeddings", "embed-daemon.js"), - captureHook: join(repoRoot, "codex", "bundle", "capture.js"), + embedDaemon: join(repoRoot, "harnesses", "codex", "bundle", "embeddings", "embed-daemon.js"), + captureHook: join(repoRoot, "harnesses", "codex", "bundle", "capture.js"), }, { agent: "cursor", @@ -39,8 +39,8 @@ const AGENTS: AgentBundle[] = [ }, { agent: "hermes", - embedDaemon: join(repoRoot, "hermes", "bundle", "embeddings", "embed-daemon.js"), - captureHook: join(repoRoot, "hermes", "bundle", "capture.js"), + embedDaemon: join(repoRoot, "harnesses", "hermes", "bundle", "embeddings", "embed-daemon.js"), + captureHook: join(repoRoot, "harnesses", "hermes", "bundle", "capture.js"), }, ]; @@ -105,11 +105,11 @@ describe("shipped shell/deeplake-shell.js — embed daemon path resolves to an e // bundled shell would look for it). const SHELL_BUNDLES: Array<[string, string, string]> = [ ["claude-code", - join(repoRoot, "claude-code", "bundle", "shell", "deeplake-shell.js"), - join(repoRoot, "claude-code", "bundle", "embeddings", "embed-daemon.js")], + join(repoRoot, "harnesses", "claude-code", "bundle", "shell", "deeplake-shell.js"), + join(repoRoot, "harnesses", "claude-code", "bundle", "embeddings", "embed-daemon.js")], ["codex", - join(repoRoot, "codex", "bundle", "shell", "deeplake-shell.js"), - join(repoRoot, "codex", "bundle", "embeddings", "embed-daemon.js")], + join(repoRoot, "harnesses", "codex", "bundle", "shell", "deeplake-shell.js"), + join(repoRoot, "harnesses", "codex", "bundle", "embeddings", "embed-daemon.js")], ["cursor", join(repoRoot, "cursor", "bundle", "shell", "deeplake-shell.js"), join(repoRoot, "cursor", "bundle", "embeddings", "embed-daemon.js")], diff --git a/tests/claude-code/notifications.test.ts b/tests/claude-code/notifications.test.ts index 3ad39bb9..8879d507 100644 --- a/tests/claude-code/notifications.test.ts +++ b/tests/claude-code/notifications.test.ts @@ -730,7 +730,7 @@ describe("drainSessionStart resilience", () => { // --------------------------------------------------------------------------- describe("bundle/session-notifications.js (built artifact)", () => { - const bundlePath = join(process.cwd(), "claude-code", "bundle", "session-notifications.js"); + const bundlePath = join(process.cwd(), "harnesses", "claude-code", "bundle", "session-notifications.js"); // spawnSync (vs execFileSync) so we can capture stdout + stderr separately. // The banner is userVisibleOnly: it must appear in the top-level diff --git a/tests/claude-code/periodic-summary-bundles.test.ts b/tests/claude-code/periodic-summary-bundles.test.ts index 95f49112..897d0b01 100644 --- a/tests/claude-code/periodic-summary-bundles.test.ts +++ b/tests/claude-code/periodic-summary-bundles.test.ts @@ -28,22 +28,22 @@ import { resolve } from "node:path"; const BUNDLE_ROOT = resolve(process.cwd()); const SESSION_END_HOOKS: Array<[string, string]> = [ - ["claude-code session-end", resolve(BUNDLE_ROOT, "claude-code", "bundle", "session-end.js")], - ["codex stop", resolve(BUNDLE_ROOT, "codex", "bundle", "stop.js")], + ["claude-code session-end", resolve(BUNDLE_ROOT, "harnesses", "claude-code", "bundle", "session-end.js")], + ["codex stop", resolve(BUNDLE_ROOT, "harnesses", "codex", "bundle", "stop.js")], ]; const CAPTURE_HOOKS: Array<[string, string]> = [ - ["claude-code capture", resolve(BUNDLE_ROOT, "claude-code", "bundle", "capture.js")], - ["codex capture", resolve(BUNDLE_ROOT, "codex", "bundle", "capture.js")], + ["claude-code capture", resolve(BUNDLE_ROOT, "harnesses", "claude-code", "bundle", "capture.js")], + ["codex capture", resolve(BUNDLE_ROOT, "harnesses", "codex", "bundle", "capture.js")], ]; const ALL_BUNDLES: Array<[string, string]> = [ ...SESSION_END_HOOKS, ...CAPTURE_HOOKS, - ["claude-code session-start", resolve(BUNDLE_ROOT, "claude-code", "bundle", "session-start.js")], - ["claude-code session-start-setup", resolve(BUNDLE_ROOT, "claude-code", "bundle", "session-start-setup.js")], - ["codex session-start", resolve(BUNDLE_ROOT, "codex", "bundle", "session-start.js")], - ["codex session-start-setup", resolve(BUNDLE_ROOT, "codex", "bundle", "session-start-setup.js")], + ["claude-code session-start", resolve(BUNDLE_ROOT, "harnesses", "claude-code", "bundle", "session-start.js")], + ["claude-code session-start-setup", resolve(BUNDLE_ROOT, "harnesses", "claude-code", "bundle", "session-start-setup.js")], + ["codex session-start", resolve(BUNDLE_ROOT, "harnesses", "codex", "bundle", "session-start.js")], + ["codex session-start-setup", resolve(BUNDLE_ROOT, "harnesses", "codex", "bundle", "session-start-setup.js")], ]; describe("bundles exist", () => { diff --git a/tests/claude-code/plugin-cache-bundles.test.ts b/tests/claude-code/plugin-cache-bundles.test.ts index c5eca6a2..f4b4a899 100644 --- a/tests/claude-code/plugin-cache-bundles.test.ts +++ b/tests/claude-code/plugin-cache-bundles.test.ts @@ -18,7 +18,7 @@ import { join } from "node:path"; * versioned dirs at SessionEnd) and that's still asserted below. */ -const claudeCodeRoot = join(process.cwd(), "claude-code"); +const claudeCodeRoot = join(process.cwd(), "harnesses", "claude-code"); const claudeCodeBundleDir = join(claudeCodeRoot, "bundle"); describe("shipped bundles contain plugin-cache safety", () => { diff --git a/tests/claude-code/plugin-cache-gc-bundle.integration.test.ts b/tests/claude-code/plugin-cache-gc-bundle.integration.test.ts index 84744de4..e4f1184e 100644 --- a/tests/claude-code/plugin-cache-gc-bundle.integration.test.ts +++ b/tests/claude-code/plugin-cache-gc-bundle.integration.test.ts @@ -13,7 +13,7 @@ import { tmpdir } from "node:os"; * Requires the bundle to be built (`npm run build`). Skipped if missing. */ -const bundlePath = resolve(process.cwd(), "claude-code", "bundle", "plugin-cache-gc.js"); +const bundlePath = resolve(process.cwd(), "harnesses", "claude-code", "bundle", "plugin-cache-gc.js"); const bundleExists = existsSync(bundlePath); function makeFakeHome(): string { @@ -132,14 +132,14 @@ describe.skipIf(!bundleExists)("plugin-cache-gc shipped bundle", () => { it("skips silently when bundle is in a local --plugin-dir layout (not under ~/.claude/plugins/cache)", () => { const sandbox = join(tmpdir(), `hivemind-dev-it-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(join(sandbox, "claude-code", "bundle"), { recursive: true }); + mkdirSync(join(sandbox, "harnesses", "claude-code", "bundle"), { recursive: true }); try { - cpSync(bundlePath, join(sandbox, "claude-code", "bundle", "plugin-cache-gc.js")); - const { stdout, stderr } = runGcBundle(sandbox, join(sandbox, "claude-code", "bundle", "plugin-cache-gc.js")); + cpSync(bundlePath, join(sandbox, "harnesses", "claude-code", "bundle", "plugin-cache-gc.js")); + const { stdout, stderr } = runGcBundle(sandbox, join(sandbox, "harnesses", "claude-code", "bundle", "plugin-cache-gc.js")); // Must not crash; output is fine to be empty. expect(stderr).not.toMatch(/TypeError|ReferenceError|Cannot find module/); // The sandbox dir should be unchanged. - expect(statSync(join(sandbox, "claude-code")).isDirectory()).toBe(true); + expect(statSync(join(sandbox, "harnesses", "claude-code")).isDirectory()).toBe(true); expect(stdout).toBe(""); } finally { rmSync(sandbox, { recursive: true, force: true }); diff --git a/tests/claude-code/plugin-cache-gc.test.ts b/tests/claude-code/plugin-cache-gc.test.ts index e8a6a191..6fa13ca0 100644 --- a/tests/claude-code/plugin-cache-gc.test.ts +++ b/tests/claude-code/plugin-cache-gc.test.ts @@ -72,7 +72,7 @@ describe("runGc", () => { }); it("logs and returns when bundleDir is not a versioned install", () => { - runGc(join(root, "claude-code", "bundle"), { log }); + runGc(join(root, "harnesses", "claude-code", "bundle"), { log }); expect(logs).toContain("not a versioned install, skipping"); }); diff --git a/tests/claude-code/plugin-cache.test.ts b/tests/claude-code/plugin-cache.test.ts index f91db797..a794e8df 100644 --- a/tests/claude-code/plugin-cache.test.ts +++ b/tests/claude-code/plugin-cache.test.ts @@ -51,7 +51,7 @@ describe("resolveVersionedPluginDir", () => { it("rejects a local --plugin-dir layout", () => { const root = mkRoot(); try { - const bundle = join(root, "claude-code", "bundle"); + const bundle = join(root, "harnesses", "claude-code", "bundle"); mkdirSync(bundle, { recursive: true }); expect(resolveVersionedPluginDir(bundle)).toBeNull(); } finally { diff --git a/tests/claude-code/plugin-version-resolution.test.ts b/tests/claude-code/plugin-version-resolution.test.ts index 4f31b0a1..e3acba08 100644 --- a/tests/claude-code/plugin-version-resolution.test.ts +++ b/tests/claude-code/plugin-version-resolution.test.ts @@ -36,11 +36,11 @@ interface AgentLayout { } const AGENTS: AgentLayout[] = [ - { agent: "claude-code", bundleDir: resolve(REPO_ROOT, "claude-code", "bundle"), manifestDir: ".claude-plugin" }, - { agent: "codex", bundleDir: resolve(REPO_ROOT, "codex", "bundle"), manifestDir: ".codex-plugin" }, + { agent: "claude-code", bundleDir: resolve(REPO_ROOT, "harnesses", "claude-code", "bundle"), manifestDir: ".claude-plugin" }, + { agent: "codex", bundleDir: resolve(REPO_ROOT, "harnesses", "codex", "bundle"), manifestDir: ".codex-plugin" }, { agent: "cursor", bundleDir: resolve(REPO_ROOT, "cursor", "bundle"), manifestDir: ".claude-plugin" }, - { agent: "hermes", bundleDir: resolve(REPO_ROOT, "hermes", "bundle"), manifestDir: ".claude-plugin" }, - { agent: "pi", bundleDir: resolve(REPO_ROOT, "pi", "bundle"), manifestDir: ".claude-plugin" }, + { agent: "hermes", bundleDir: resolve(REPO_ROOT, "harnesses", "hermes", "bundle"), manifestDir: ".claude-plugin" }, + { agent: "pi", bundleDir: resolve(REPO_ROOT, "harnesses", "pi", "bundle"), manifestDir: ".claude-plugin" }, ]; describe("plugin_version stamps a non-empty value for every shipped agent", () => { @@ -64,12 +64,12 @@ describe("plugin_version is wired into every agent's capture INSERT", () => { // SQL for every agent's session-event writer. Mirrors the spirit of // wiki-worker-upload-sql.test.ts. const CAPTURE_BUNDLES: Array<[string, string]> = [ - ["claude-code capture", resolve(REPO_ROOT, "claude-code", "bundle", "capture.js")], - ["codex capture", resolve(REPO_ROOT, "codex", "bundle", "capture.js")], + ["claude-code capture", resolve(REPO_ROOT, "harnesses", "claude-code", "bundle", "capture.js")], + ["codex capture", resolve(REPO_ROOT, "harnesses", "codex", "bundle", "capture.js")], ["cursor capture", resolve(REPO_ROOT, "cursor", "bundle", "capture.js")], - ["hermes capture", resolve(REPO_ROOT, "hermes", "bundle", "capture.js")], - ["codex stop", resolve(REPO_ROOT, "codex", "bundle", "stop.js")], - ["openclaw index", resolve(REPO_ROOT, "openclaw", "dist", "index.js")], + ["hermes capture", resolve(REPO_ROOT, "harnesses", "hermes", "bundle", "capture.js")], + ["codex stop", resolve(REPO_ROOT, "harnesses", "codex", "bundle", "stop.js")], + ["openclaw index", resolve(REPO_ROOT, "harnesses", "openclaw", "dist", "index.js")], ]; it.each(CAPTURE_BUNDLES)("%s INSERT lists plugin_version column", (_label, path) => { @@ -84,10 +84,10 @@ describe("plugin_version is wired into every agent's capture INSERT", () => { describe("plugin_version is wired into every agent's session-start placeholder INSERT", () => { const PLACEHOLDER_BUNDLES: Array<[string, string]> = [ - ["claude-code session-start", resolve(REPO_ROOT, "claude-code", "bundle", "session-start.js")], - ["codex session-start-setup", resolve(REPO_ROOT, "codex", "bundle", "session-start-setup.js")], + ["claude-code session-start", resolve(REPO_ROOT, "harnesses", "claude-code", "bundle", "session-start.js")], + ["codex session-start-setup", resolve(REPO_ROOT, "harnesses", "codex", "bundle", "session-start-setup.js")], ["cursor session-start", resolve(REPO_ROOT, "cursor", "bundle", "session-start.js")], - ["hermes session-start", resolve(REPO_ROOT, "hermes", "bundle", "session-start.js")], + ["hermes session-start", resolve(REPO_ROOT, "harnesses", "hermes", "bundle", "session-start.js")], ]; it.each(PLACEHOLDER_BUNDLES)("%s placeholder INSERT lists plugin_version column", (_label, path) => { diff --git a/tests/claude-code/pre-tool-use.test.ts b/tests/claude-code/pre-tool-use.test.ts index be3a7724..6313c39d 100644 --- a/tests/claude-code/pre-tool-use.test.ts +++ b/tests/claude-code/pre-tool-use.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { execFileSync } from "node:child_process"; import { join } from "node:path"; -const bundleDir = join(process.cwd(), "claude-code", "bundle"); +const bundleDir = join(process.cwd(), "harnesses", "claude-code", "bundle"); /** * Pipe JSON into the CC pre-tool-use hook and return parsed output. diff --git a/tests/claude-code/session-start.test.ts b/tests/claude-code/session-start.test.ts index 72eb0604..3ae27ce0 100644 --- a/tests/claude-code/session-start.test.ts +++ b/tests/claude-code/session-start.test.ts @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"; import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; -const ccRoot = join(process.cwd(), "claude-code"); +const ccRoot = join(process.cwd(), "harnesses", "claude-code"); const bundleDir = join(ccRoot, "bundle"); // ── hooks.json structure tests ────────────────────────────────────────────── diff --git a/tests/claude-code/shell-bundle-sql-trace-silence.test.ts b/tests/claude-code/shell-bundle-sql-trace-silence.test.ts index f238674e..6b9b21cd 100644 --- a/tests/claude-code/shell-bundle-sql-trace-silence.test.ts +++ b/tests/claude-code/shell-bundle-sql-trace-silence.test.ts @@ -29,7 +29,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const BUNDLE_PATH = join(process.cwd(), "claude-code", "bundle", "shell", "deeplake-shell.js"); +const BUNDLE_PATH = join(process.cwd(), "harnesses", "claude-code", "bundle", "shell", "deeplake-shell.js"); describe("shell bundle one-shot: SQL trace silence (fix #3)", () => { it("does not write [deeplake-sql] to stderr even when trace env vars are set", () => { diff --git a/tests/claude-code/skillify-agent-roots.test.ts b/tests/claude-code/skillify-agent-roots.test.ts index 06ae4eb7..b113ea02 100644 --- a/tests/claude-code/skillify-agent-roots.test.ts +++ b/tests/claude-code/skillify-agent-roots.test.ts @@ -20,6 +20,7 @@ const canonical = (home: string) => join(home, ".claude", "skills"); function installCodex(home: string): void { mkdirSync(join(home, ".codex"), { recursive: true }); } function installHermes(home: string): void { mkdirSync(join(home, ".hermes"), { recursive: true }); } function installPi(home: string): void { mkdirSync(join(home, ".pi", "agent"), { recursive: true }); } +function installCursor(home: string): void { mkdirSync(join(home, ".cursor"), { recursive: true }); } describe("detectAgentSkillsRoots", () => { it("returns empty when no agent config dirs exist", () => { @@ -102,6 +103,23 @@ describe("detectAgentSkillsRoots", () => { expect(result).toContain(join(tmpHome, ".pi", "agent", "skills")); }); + it("includes ~/.cursor/skills-cursor when cursor is installed", () => { + installCursor(tmpHome); + expect(detectAgentSkillsRoots(canonical(tmpHome), tmpHome)).toEqual([ + join(tmpHome, ".cursor", "skills-cursor"), + ]); + }); + + it("includes project .cursor/skills when cursor is installed and projectRoot is passed", () => { + installCursor(tmpHome); + const project = join(tmpHome, "my-repo"); + mkdirSync(project, { recursive: true }); + expect(detectAgentSkillsRoots(canonical(tmpHome), tmpHome, project)).toEqual([ + join(tmpHome, ".cursor", "skills-cursor"), + join(project, ".cursor", "skills"), + ]); + }); + it("ignores a regular file at ~/.codex / ~/.hermes / ~/.pi/agent (parent must be a directory)", () => { // existsSync returns true for files too, so a stray ~/.codex file // (e.g. from `touch ~/.codex` by mistake) shouldn't trick the diff --git a/tests/claude-code/skillify-bundle-scan.test.ts b/tests/claude-code/skillify-bundle-scan.test.ts index 0f878f70..be30785d 100644 --- a/tests/claude-code/skillify-bundle-scan.test.ts +++ b/tests/claude-code/skillify-bundle-scan.test.ts @@ -19,7 +19,8 @@ const AGENTS = ["claude-code", "codex", "cursor", "hermes", "openclaw"] as const function bundlePath(agent: string, file: string): string { const dir = agent === "openclaw" ? "dist" : "bundle"; - return join(ROOT, agent, dir, file); + const base = agent === "cursor" ? join(ROOT, agent) : join(ROOT, "harnesses", agent); + return join(base, dir, file); } describe("skillify-worker bundle is shipped per agent", () => { @@ -135,8 +136,8 @@ describe("legacy state-dir migration is shipped in every agent's bundle", () => }); } - it("openclaw/dist/index.js: inlined migration present and called before fsMkdir", () => { - const text = readFileSync(join(ROOT, "openclaw", "dist", "index.js"), "utf-8"); + it("harnesses/openclaw/dist/index.js: inlined migration present and called before fsMkdir", () => { + const text = readFileSync(join(ROOT, "harnesses", "openclaw", "dist", "index.js"), "utf-8"); expect(text).toContain("function migrateOpenclawSkillifyLegacyStateDir"); // Must be called inside tryAcquireOpenclawSkillifyLock before the fsMkdir. // The order matters: once fsMkdir creates the new dir, the migration 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<void> { + 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/claude-code/skillify-session-start-injection.test.ts b/tests/claude-code/skillify-session-start-injection.test.ts index 1a532bd6..3230252b 100644 --- a/tests/claude-code/skillify-session-start-injection.test.ts +++ b/tests/claude-code/skillify-session-start-injection.test.ts @@ -22,28 +22,28 @@ const BUNDLE_ROOT = resolve(process.cwd()); // AGENT_CHANNELS.md → Codex), so the skillify command list lives in the // auto-loaded `hivemind-memory` skill instead of the hook context. const SESSION_START_BUNDLES: Array<[string, string]> = [ - ["claude-code", resolve(BUNDLE_ROOT, "claude-code", "bundle", "session-start.js")], + ["claude-code", resolve(BUNDLE_ROOT, "harnesses", "claude-code", "bundle", "session-start.js")], ["cursor", resolve(BUNDLE_ROOT, "cursor", "bundle", "session-start.js")], - ["hermes", resolve(BUNDLE_ROOT, "hermes", "bundle", "session-start.js")], + ["hermes", resolve(BUNDLE_ROOT, "harnesses", "hermes", "bundle", "session-start.js")], ]; // Pi and OpenClaw don't go through the same esbuild bundle pipeline: -// - Pi ships pi/extension-source/hivemind.ts as raw .ts (pi compiles it) -// - OpenClaw exposes its surface via openclaw/skills/SKILL.md (loaded by +// - Pi ships harnesses/pi/extension-source/hivemind.ts as raw .ts (pi compiles it) +// - OpenClaw exposes its surface via harnesses/openclaw/skills/SKILL.md (loaded by // the openclaw runtime's skill index, not bundled JS) // Codex sits here too: its skillify discoverability lives in -// codex/skills/deeplake-memory/SKILL.md (auto-loaded), not in the hook bundle. +// harnesses/codex/skills/deeplake-memory/SKILL.md (auto-loaded), not in the hook bundle. const NON_BUNDLE_SURFACES: Array<[string, string]> = [ - ["pi-extension-source", resolve(BUNDLE_ROOT, "pi", "extension-source", "hivemind.ts")], - ["openclaw-skill", resolve(BUNDLE_ROOT, "openclaw", "skills", "SKILL.md")], - ["codex-skill", resolve(BUNDLE_ROOT, "codex", "skills", "deeplake-memory", "SKILL.md")], + ["pi-extension-source", resolve(BUNDLE_ROOT, "harnesses", "pi", "extension-source", "hivemind.ts")], + ["openclaw-skill", resolve(BUNDLE_ROOT, "harnesses", "openclaw", "skills", "SKILL.md")], + ["codex-skill", resolve(BUNDLE_ROOT, "harnesses", "codex", "skills", "deeplake-memory", "SKILL.md")], ]; // Codex bundle — separate matrix because it asserts a NEGATIVE: the slim // invariant says the bundle MUST NOT inline the verbose skillify command list // (every byte there is shown to the user as `hook context: ...`). const CODEX_BUNDLE: [string, string] = [ - "codex", resolve(BUNDLE_ROOT, "codex", "bundle", "session-start.js"), + "codex", resolve(BUNDLE_ROOT, "harnesses", "codex", "bundle", "session-start.js"), ]; describe("skillify SessionStart injection (per-agent bundles)", () => { @@ -119,7 +119,7 @@ describe("Codex bundle slim invariant + skill-as-source-of-truth", () => { it("Codex bundle does NOT inline the skillify command list (it lives in the skill)", () => { const text = readFileSync(CODEX_BUNDLE[1], "utf-8"); - // The skillify list belongs in codex/skills/deeplake-memory/SKILL.md + // The skillify list belongs in harnesses/codex/skills/deeplake-memory/SKILL.md // (auto-loaded by codex's skill loader) — emitting it via stdout would // dump ~50 lines into the user's `hook context: ...` history cell. expect(text).not.toMatch(/skillify pull --user/); @@ -183,7 +183,7 @@ describe("skillify discoverability on non-bundle agent surfaces (Pi + OpenClaw + // npm-bin unification) and now propagated. OpenClaw uses /hivemind_* // plugin-native commands which are a different surface — covered by // openclaw.plugin.json contracts.commands, not by this assertion. - const text = readFileSync(resolve(BUNDLE_ROOT, "pi", "extension-source", "hivemind.ts"), "utf-8"); + const text = readFileSync(resolve(BUNDLE_ROOT, "harnesses", "pi", "extension-source", "hivemind.ts"), "utf-8"); expect(text).toMatch(/Organization management/); expect(text).toMatch(/hivemind whoami\b/); expect(text).toMatch(/hivemind org list\b/); @@ -197,8 +197,8 @@ describe("Pi skillify worker (mining) wiring", () => { // assertions catch any regression that drops the bundle entry, removes // the install copy, or unwires the spawn call. - it("ships pi/bundle/skillify-worker.js after build", () => { - const p = resolve(BUNDLE_ROOT, "pi", "bundle", "skillify-worker.js"); + it("ships harnesses/pi/bundle/skillify-worker.js after build", () => { + const p = resolve(BUNDLE_ROOT, "harnesses", "pi", "bundle", "skillify-worker.js"); expect(existsSync(p)).toBe(true); }); @@ -208,17 +208,17 @@ describe("Pi skillify worker (mining) wiring", () => { expect(cfg).toMatch(/dist\/src\/skillify\/skillify-worker\.js[^"]*"\s*,\s*out:\s*"skillify-worker"/); }); - it("install-pi.ts copies pi/bundle/skillify-worker.js to ~/.pi/agent/hivemind/", () => { + it("install-pi.ts copies harnesses/pi/bundle/skillify-worker.js to ~/.pi/agent/hivemind/", () => { const src = readFileSync(resolve(BUNDLE_ROOT, "src", "cli", "install-pi.ts"), "utf-8"); expect(src).toMatch(/SKILLIFY_WORKER_PATH\s*=/); - // join(pkgRoot(), "pi", "bundle", "skillify-worker.js") — the source path + // join(pkgRoot(), "harnesses", "pi", "bundle", "skillify-worker.js") — the source path expect(src).toMatch(/"pi",\s*"bundle",\s*"skillify-worker\.js"/); // copyFileSync(srcSkillifyWorker, SKILLIFY_WORKER_PATH) — the install step expect(src).toMatch(/copyFileSync\(srcSkillifyWorker,\s*SKILLIFY_WORKER_PATH\)/); }); it("pi extension defines spawnPiSkillifyWorker and wires it into session_shutdown", () => { - const ext = readFileSync(resolve(BUNDLE_ROOT, "pi", "extension-source", "hivemind.ts"), "utf-8"); + const ext = readFileSync(resolve(BUNDLE_ROOT, "harnesses", "pi", "extension-source", "hivemind.ts"), "utf-8"); // Function exists expect(ext).toMatch(/function spawnPiSkillifyWorker\b/); // Path const points at the right install location @@ -232,7 +232,7 @@ describe("Pi skillify worker (mining) wiring", () => { it("pi skillify worker bundle embeds the same worker code as the other agents", () => { // Same shared module — guard against an accidental empty bundle by // checking the canonical entry-point + module markers are present. - const text = readFileSync(resolve(BUNDLE_ROOT, "pi", "bundle", "skillify-worker.js"), "utf-8"); + const text = readFileSync(resolve(BUNDLE_ROOT, "harnesses", "pi", "bundle", "skillify-worker.js"), "utf-8"); // The worker reads its config from process.argv[2] expect(text).toMatch(/process\.argv\[2\]/); // The worker writes to the skills table via INSERT (append-only design) @@ -247,12 +247,12 @@ describe("Pi skillify worker (mining) wiring", () => { describe("OpenClaw skillify worker (mining) wiring", () => { // OpenClaw mines via a separate bundled worker spawned from the agent_end // hook. The worker bundle is built as a second openclaw esbuild entry - // landing at openclaw/dist/skillify-worker.js (sibling of index.js). + // landing at harnesses/openclaw/dist/skillify-worker.js (sibling of index.js). // install-openclaw.ts already copies the entire dist/ recursively, so // no install step change is required. - it("ships openclaw/dist/skillify-worker.js after build", () => { - const p = resolve(BUNDLE_ROOT, "openclaw", "dist", "skillify-worker.js"); + it("ships harnesses/openclaw/dist/skillify-worker.js after build", () => { + const p = resolve(BUNDLE_ROOT, "harnesses", "openclaw", "dist", "skillify-worker.js"); expect(existsSync(p)).toBe(true); }); @@ -264,11 +264,11 @@ describe("OpenClaw skillify worker (mining) wiring", () => { expect(cfg).toMatch(/"skillify-worker":\s*"dist\/src\/skillify\/skillify-worker\.js"/); // Window is generous to leave room for the bundle's comments + the // env-var → globalThis.__hivemind_tuning__ define dispatch table. - expect(cfg).toMatch(/outdir:\s*"openclaw\/dist"[\s\S]{0,2000}skillify-worker/); + expect(cfg).toMatch(/outdir:\s*"harnesses\/openclaw\/dist"[\s\S]{0,2000}skillify-worker/); }); - it("openclaw/src/index.ts bypasses the child_process stub via createRequire", () => { - const src = readFileSync(resolve(BUNDLE_ROOT, "openclaw", "src", "index.ts"), "utf-8"); + it("harnesses/openclaw/src/index.ts bypasses the child_process stub via createRequire", () => { + const src = readFileSync(resolve(BUNDLE_ROOT, "harnesses", "openclaw", "src", "index.ts"), "utf-8"); // The main openclaw bundle stubs out node:child_process to drop CC dead // code. createRequire(import.meta.url) is the runtime escape hatch — it // is NOT intercepted by esbuild's static analysis. @@ -276,8 +276,8 @@ describe("OpenClaw skillify worker (mining) wiring", () => { expect(src).toMatch(/requireFromOpenclaw\("node:child_process"\)/); }); - it("openclaw/src/index.ts defines spawnOpenclawSkillifyWorker and wires it into agent_end", () => { - const src = readFileSync(resolve(BUNDLE_ROOT, "openclaw", "src", "index.ts"), "utf-8"); + it("harnesses/openclaw/src/index.ts defines spawnOpenclawSkillifyWorker and wires it into agent_end", () => { + const src = readFileSync(resolve(BUNDLE_ROOT, "harnesses", "openclaw", "src", "index.ts"), "utf-8"); expect(src).toMatch(/function spawnOpenclawSkillifyWorker\b/); // OPENCLAW_SKILLIFY_WORKER_PATH must be a sibling of import.meta.url expect(src).toMatch(/OPENCLAW_SKILLIFY_WORKER_PATH\s*=\s*joinPath\(__openclaw_dirname,\s*"skillify-worker\.js"\)/); @@ -300,7 +300,7 @@ describe("OpenClaw skillify worker (mining) wiring", () => { }); it("openclaw bundle preserves the createRequire spawn (not stubbed by esbuild)", () => { - const text = readFileSync(resolve(BUNDLE_ROOT, "openclaw", "dist", "index.js"), "utf-8"); + const text = readFileSync(resolve(BUNDLE_ROOT, "harnesses", "openclaw", "dist", "index.js"), "utf-8"); // After bundling, the createRequire + dynamic require call must still be there expect(text).toMatch(/createRequire\(import\.meta\.url\)/); expect(text).toMatch(/requireFromOpenclaw\("node:child_process"\)/); @@ -321,7 +321,7 @@ describe("OpenClaw skillify worker (mining) wiring", () => { // and pass it as `gateAgent`; the worker dispatches `runGate` against // that delegate while keeping `agent: "openclaw"` for source_agent // provenance in the skills table. Regression guard. - const src = readFileSync(resolve(BUNDLE_ROOT, "openclaw", "src", "index.ts"), "utf-8"); + const src = readFileSync(resolve(BUNDLE_ROOT, "harnesses", "openclaw", "src", "index.ts"), "utf-8"); expect(src).toMatch(/function detectOpenclawGateAgent\b/); // The candidate list: the five CLIs the worker's gate-runner knows about. expect(src).toMatch(/"claude_code",\s*"claude"/); @@ -334,14 +334,14 @@ describe("OpenClaw skillify worker (mining) wiring", () => { // gateAgent threaded into the worker config — same key the worker reads. expect(src).toMatch(/gateAgent,/); // Bundled output must preserve the detection + threading. - const text = readFileSync(resolve(BUNDLE_ROOT, "openclaw", "dist", "index.js"), "utf-8"); + const text = readFileSync(resolve(BUNDLE_ROOT, "harnesses", "openclaw", "dist", "index.js"), "utf-8"); expect(text).toMatch(/detectOpenclawGateAgent/); expect(text).toMatch(/gateAgent/); expect(text).toMatch(/no delegate gate CLI found/); }); it("openclaw worker bundle embeds the same shared worker code as other agents", () => { - const text = readFileSync(resolve(BUNDLE_ROOT, "openclaw", "dist", "skillify-worker.js"), "utf-8"); + const text = readFileSync(resolve(BUNDLE_ROOT, "harnesses", "openclaw", "dist", "skillify-worker.js"), "utf-8"); expect(text).toMatch(/process\.argv\[2\]/); expect(text).toMatch(/INSERT INTO/); expect(text).toMatch(/gate-runner|runGate/); diff --git a/tests/claude-code/utils-version-check.test.ts b/tests/claude-code/utils-version-check.test.ts index 4d94f683..7d9745a1 100644 --- a/tests/claude-code/utils-version-check.test.ts +++ b/tests/claude-code/utils-version-check.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -42,17 +42,17 @@ describe("getInstalledVersion — plugin-manifest branch", () => { let root: string; beforeEach(() => { - root = join(tmpdir(), `hm-uvc-${Date.now()}-${Math.random().toString(36).slice(2)}`); + root = mkdtempSync(join(tmpdir(), "hm-uvc-")); }); afterEach(() => { rmSync(root, { recursive: true, force: true }); }); it("prefers <bundle>/../<manifestDir>/plugin.json when present", () => { - const bundle = join(root, "claude-code", "bundle"); - mkdirSync(join(root, "claude-code", ".claude-plugin"), { recursive: true }); + const bundle = join(root, "harnesses", "claude-code", "bundle"); + mkdirSync(join(root, "harnesses", "claude-code", ".claude-plugin"), { recursive: true }); mkdirSync(bundle, { recursive: true }); - writeFileSync(join(root, "claude-code", ".claude-plugin", "plugin.json"), + writeFileSync(join(root, "harnesses", "claude-code", ".claude-plugin", "plugin.json"), JSON.stringify({ version: "1.2.3" })); writeFileSync(join(root, "package.json"), JSON.stringify({ name: "@deeplake/hivemind", version: "9.9.9" })); @@ -72,7 +72,7 @@ describe("getInstalledVersion — plugin-manifest branch", () => { describe("getInstalledVersion — .hivemind_version stamp branch", () => { let root: string; beforeEach(() => { - root = join(tmpdir(), `hm-uvc-${Date.now()}-${Math.random().toString(36).slice(2)}`); + root = mkdtempSync(join(tmpdir(), "hm-uvc-")); }); afterEach(() => { rmSync(root, { recursive: true, force: true }); }); diff --git a/tests/claude-code/version-check.test.ts b/tests/claude-code/version-check.test.ts index 4d01aad7..9a107626 100644 --- a/tests/claude-code/version-check.test.ts +++ b/tests/claude-code/version-check.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { @@ -23,8 +23,7 @@ describe("getInstalledVersion", () => { let root: string; beforeEach(() => { - root = join(tmpdir(), `hivemind-version-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(root, { recursive: true }); + root = mkdtempSync(join(tmpdir(), "hivemind-version-")); }); afterEach(() => { @@ -32,27 +31,27 @@ describe("getInstalledVersion", () => { }); it("prefers plugin manifest when present", () => { - const bundleDir = join(root, "claude-code", "bundle"); - mkdirSync(join(root, "claude-code", ".claude-plugin"), { recursive: true }); + const bundleDir = join(root, "harnesses", "claude-code", "bundle"); + mkdirSync(join(root, "harnesses", "claude-code", ".claude-plugin"), { recursive: true }); mkdirSync(bundleDir, { recursive: true }); - writeFileSync(join(root, "claude-code", ".claude-plugin", "plugin.json"), JSON.stringify({ version: "0.6.37" })); + writeFileSync(join(root, "harnesses", "claude-code", ".claude-plugin", "plugin.json"), JSON.stringify({ version: "0.6.37" })); writeFileSync(join(root, "package.json"), JSON.stringify({ name: "hivemind", version: "0.1.0" })); expect(getInstalledVersion(bundleDir, ".claude-plugin")).toBe("0.6.37"); }); it("falls back to package.json when plugin manifest has no version", () => { - const bundleDir = join(root, "claude-code", "bundle"); - mkdirSync(join(root, "claude-code", ".claude-plugin"), { recursive: true }); + const bundleDir = join(root, "harnesses", "claude-code", "bundle"); + mkdirSync(join(root, "harnesses", "claude-code", ".claude-plugin"), { recursive: true }); mkdirSync(bundleDir, { recursive: true }); - writeFileSync(join(root, "claude-code", ".claude-plugin", "plugin.json"), JSON.stringify({ name: "hivemind" })); + writeFileSync(join(root, "harnesses", "claude-code", ".claude-plugin", "plugin.json"), JSON.stringify({ name: "hivemind" })); writeFileSync(join(root, "package.json"), JSON.stringify({ name: "hivemind", version: "0.6.41" })); expect(getInstalledVersion(bundleDir, ".claude-plugin")).toBe("0.6.41"); }); it("walks up to package.json when plugin manifest is absent", () => { - const bundleDir = join(root, "codex", "bundle"); + const bundleDir = join(root, "harnesses", "codex", "bundle"); mkdirSync(bundleDir, { recursive: true }); writeFileSync(join(root, "package.json"), JSON.stringify({ name: "hivemind-codex", version: "0.6.40" })); @@ -68,10 +67,10 @@ describe("getInstalledVersion", () => { }); it("returns null when the plugin manifest is invalid json and no package matches", () => { - const bundleDir = join(root, "claude-code", "bundle"); - mkdirSync(join(root, "claude-code", ".claude-plugin"), { recursive: true }); + const bundleDir = join(root, "harnesses", "claude-code", "bundle"); + mkdirSync(join(root, "harnesses", "claude-code", ".claude-plugin"), { recursive: true }); mkdirSync(bundleDir, { recursive: true }); - writeFileSync(join(root, "claude-code", ".claude-plugin", "plugin.json"), "{bad-json"); + writeFileSync(join(root, "harnesses", "claude-code", ".claude-plugin", "plugin.json"), "{bad-json"); expect(getInstalledVersion(bundleDir, ".claude-plugin")).toBeNull(); }); diff --git a/tests/claude-code/version-define-bundles.test.ts b/tests/claude-code/version-define-bundles.test.ts index 9813810c..f94aa71a 100644 --- a/tests/claude-code/version-define-bundles.test.ts +++ b/tests/claude-code/version-define-bundles.test.ts @@ -31,10 +31,10 @@ function listBundleFiles(dir: string): string[] { } const BUNDLE_DIRS = [ - ["claude-code", resolve(ROOT, "claude-code", "bundle")], - ["codex", resolve(ROOT, "codex", "bundle")], + ["claude-code", resolve(ROOT, "harnesses", "claude-code", "bundle")], + ["codex", resolve(ROOT, "harnesses", "codex", "bundle")], ["cursor", resolve(ROOT, "cursor", "bundle")], - ["hermes", resolve(ROOT, "hermes", "bundle")], + ["hermes", resolve(ROOT, "harnesses", "hermes", "bundle")], ]; for (const [label, dir] of BUNDLE_DIRS) { diff --git a/tests/claude-code/wiki-next-steps-contract.test.ts b/tests/claude-code/wiki-next-steps-contract.test.ts index eef85def..3d662912 100644 --- a/tests/claude-code/wiki-next-steps-contract.test.ts +++ b/tests/claude-code/wiki-next-steps-contract.test.ts @@ -26,7 +26,7 @@ import { WIKI_PROMPT_TEMPLATE as HERMES_TEMPLATE } from "../../src/hooks/hermes/ // executed here, so lift the template literal from source — same approach as // tests/pi/pi-extension-source.test.ts. const PI_TEMPLATE = readFileSync( - join(process.cwd(), "pi", "extension-source", "hivemind.ts"), + join(process.cwd(), "harnesses", "pi", "extension-source", "hivemind.ts"), "utf-8", ); diff --git a/tests/claude-code/wiki-worker-plugin-version.test.ts b/tests/claude-code/wiki-worker-plugin-version.test.ts index 256dec0b..da93915e 100644 --- a/tests/claude-code/wiki-worker-plugin-version.test.ts +++ b/tests/claude-code/wiki-worker-plugin-version.test.ts @@ -7,7 +7,7 @@ import { join } from "node:path"; * Functional pluginVersion threading test for the cursor / hermes / pi * wiki-worker variants. * - * `claude-code/tests/wiki-worker.test.ts` already exhaustively tests the + * `harnesses/claude-code/tests/wiki-worker.test.ts` already exhaustively tests the * shared worker structure for the claude-code variant. This file is a * narrower guard: for each of the three sibling agents, the worker MUST * read `cfg.pluginVersion` from its spawn-config JSON and forward it to diff --git a/tests/claude-code/wiki-worker-upload-sql.test.ts b/tests/claude-code/wiki-worker-upload-sql.test.ts index 7ccec8be..849c5dfd 100644 --- a/tests/claude-code/wiki-worker-upload-sql.test.ts +++ b/tests/claude-code/wiki-worker-upload-sql.test.ts @@ -15,8 +15,8 @@ import { resolve } from "node:path"; const ROOT = process.cwd(); const BUNDLES: Array<[string, string]> = [ - ["claude-code", resolve(ROOT, "claude-code", "bundle", "wiki-worker.js")], - ["codex", resolve(ROOT, "codex", "bundle", "wiki-worker.js")], + ["claude-code", resolve(ROOT, "harnesses", "claude-code", "bundle", "wiki-worker.js")], + ["codex", resolve(ROOT, "harnesses", "codex", "bundle", "wiki-worker.js")], ]; for (const [label, path] of BUNDLES) { diff --git a/tests/cli/cli-embeddings.test.ts b/tests/cli/cli-embeddings.test.ts index afa66c06..50520f91 100644 --- a/tests/cli/cli-embeddings.test.ts +++ b/tests/cli/cli-embeddings.test.ts @@ -76,9 +76,9 @@ describe("findHivemindInstalls", () => { expect(installs.map(i => i.id).sort()).toEqual(["claude (0.7.0)", "claude (0.7.1)"]); }); - it("supports the alternate <version>/claude-code/bundle layout", () => { + it("supports the alternate <version>/harnesses/claude-code/bundle layout", () => { const cache = join(tmpHome, ".claude", "plugins", "cache", "hivemind", "hivemind"); - fakeBundleAt(join(cache, "0.7.0", "claude-code")); + fakeBundleAt(join(cache, "0.7.0", "harnesses", "claude-code")); const installs = findHivemindInstalls(tmpHome); expect(installs).toHaveLength(1); expect(installs[0].id).toBe("claude (0.7.0)"); diff --git a/tests/cli/cli-install-codex-fs.test.ts b/tests/cli/cli-install-codex-fs.test.ts index 9694b24f..8bb2e66d 100644 --- a/tests/cli/cli-install-codex-fs.test.ts +++ b/tests/cli/cli-install-codex-fs.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, utimesSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, utimesSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -29,7 +29,7 @@ vi.mock("node:child_process", () => ({ })); beforeEach(() => { - tmpRoot = join(tmpdir(), `hm-codex-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tmpRoot = mkdtempSync(join(tmpdir(), "hm-codex-")); tmpHome = join(tmpRoot, "home"); tmpPkg = join(tmpRoot, "pkg"); @@ -37,11 +37,11 @@ beforeEach(() => { mkdirSync(join(tmpHome, ".codex"), { recursive: true }); // Mock package layout: pkgRoot/codex/{bundle,skills}/<files> - mkdirSync(join(tmpPkg, "codex", "bundle"), { recursive: true }); - writeFileSync(join(tmpPkg, "codex", "bundle", "session-start.js"), "// fake bundle file"); - writeFileSync(join(tmpPkg, "codex", "bundle", "capture.js"), "// fake bundle file"); - mkdirSync(join(tmpPkg, "codex", "skills", "deeplake-memory"), { recursive: true }); - writeFileSync(join(tmpPkg, "codex", "skills", "deeplake-memory", "SKILL.md"), "fake skill body"); + mkdirSync(join(tmpPkg, "harnesses", "codex", "bundle"), { recursive: true }); + writeFileSync(join(tmpPkg, "harnesses", "codex", "bundle", "session-start.js"), "// fake bundle file"); + writeFileSync(join(tmpPkg, "harnesses", "codex", "bundle", "capture.js"), "// fake bundle file"); + mkdirSync(join(tmpPkg, "harnesses", "codex", "skills", "deeplake-memory"), { recursive: true }); + writeFileSync(join(tmpPkg, "harnesses", "codex", "skills", "deeplake-memory", "SKILL.md"), "fake skill body"); // Mock package.json so getVersion() resolves to a known value. writeFileSync(join(tmpPkg, "package.json"), JSON.stringify({ version: "1.2.3" })); @@ -271,14 +271,14 @@ describe("installCodex — happy path", () => { }); it("warns and skips the symlink (without throwing) when the skill source is missing", async () => { - rmSync(join(tmpPkg, "codex", "skills", "deeplake-memory"), { recursive: true, force: true }); + rmSync(join(tmpPkg, "harnesses", "codex", "skills", "deeplake-memory"), { recursive: true, force: true }); const { installCodex } = await importInstaller(); expect(() => installCodex()).not.toThrow(); expect(existsSync(join(tmpHome, ".agents", "skills", "hivemind-memory"))).toBe(false); }); it("throws when the bundle source is missing (build hasn't run)", async () => { - rmSync(join(tmpPkg, "codex", "bundle"), { recursive: true, force: true }); + rmSync(join(tmpPkg, "harnesses", "codex", "bundle"), { recursive: true, force: true }); const { installCodex } = await importInstaller(); expect(() => installCodex()).toThrow(/Codex bundle missing/); }); 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/); }); diff --git a/tests/cli/cli-install-hermes.test.ts b/tests/cli/cli-install-hermes.test.ts index e48a770f..198d8aba 100644 --- a/tests/cli/cli-install-hermes.test.ts +++ b/tests/cli/cli-install-hermes.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import * as yaml from "js-yaml"; @@ -19,16 +19,16 @@ let tmpHome: string; let tmpPkg: string; beforeEach(() => { - tmpRoot = join(tmpdir(), `hm-hermes-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tmpRoot = mkdtempSync(join(tmpdir(), "hm-hermes-")); tmpHome = join(tmpRoot, "home"); tmpPkg = join(tmpRoot, "pkg"); mkdirSync(tmpHome, { recursive: true }); - mkdirSync(join(tmpPkg, "hermes", "bundle"), { recursive: true }); - writeFileSync(join(tmpPkg, "hermes", "bundle", "session-start.js"), "// fake bundle"); - writeFileSync(join(tmpPkg, "hermes", "bundle", "capture.js"), "// fake bundle"); - writeFileSync(join(tmpPkg, "hermes", "bundle", "pre-tool-use.js"), "// fake bundle"); - writeFileSync(join(tmpPkg, "hermes", "bundle", "session-end.js"), "// fake bundle"); + mkdirSync(join(tmpPkg, "harnesses", "hermes", "bundle"), { recursive: true }); + writeFileSync(join(tmpPkg, "harnesses", "hermes", "bundle", "session-start.js"), "// fake bundle"); + writeFileSync(join(tmpPkg, "harnesses", "hermes", "bundle", "capture.js"), "// fake bundle"); + writeFileSync(join(tmpPkg, "harnesses", "hermes", "bundle", "pre-tool-use.js"), "// fake bundle"); + writeFileSync(join(tmpPkg, "harnesses", "hermes", "bundle", "session-end.js"), "// fake bundle"); mkdirSync(join(tmpPkg, "mcp", "bundle"), { recursive: true }); writeFileSync(join(tmpPkg, "mcp", "bundle", "server.js"), "// fake mcp server"); @@ -136,7 +136,7 @@ describe("installHermes — cold install", () => { }); it("throws when the hermes hook bundle source is missing (build hasn't run)", async () => { - rmSync(join(tmpPkg, "hermes", "bundle"), { recursive: true, force: true }); + rmSync(join(tmpPkg, "harnesses", "hermes", "bundle"), { recursive: true, force: true }); const { installHermes } = await importInstaller(); expect(() => installHermes()).toThrow(/Hermes bundle missing/); }); diff --git a/tests/cli/cli-install-openclaw.test.ts b/tests/cli/cli-install-openclaw.test.ts index 59204199..ab13c315 100644 --- a/tests/cli/cli-install-openclaw.test.ts +++ b/tests/cli/cli-install-openclaw.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -14,17 +14,17 @@ let tmpHome: string; let tmpPkg: string; beforeEach(() => { - tmpRoot = join(tmpdir(), `hm-claw-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tmpRoot = mkdtempSync(join(tmpdir(), "hm-claw-")); tmpHome = join(tmpRoot, "home"); tmpPkg = join(tmpRoot, "pkg"); mkdirSync(tmpHome, { recursive: true }); - mkdirSync(join(tmpPkg, "openclaw", "dist"), { recursive: true }); - writeFileSync(join(tmpPkg, "openclaw", "dist", "index.js"), "// fake dist"); - writeFileSync(join(tmpPkg, "openclaw", "openclaw.plugin.json"), JSON.stringify({ name: "hivemind", version: "1.2.3" })); - writeFileSync(join(tmpPkg, "openclaw", "package.json"), JSON.stringify({ name: "hivemind", version: "1.2.3" })); - mkdirSync(join(tmpPkg, "openclaw", "skills"), { recursive: true }); - writeFileSync(join(tmpPkg, "openclaw", "skills", "hivemind.md"), "skill body"); + mkdirSync(join(tmpPkg, "harnesses", "openclaw", "dist"), { recursive: true }); + writeFileSync(join(tmpPkg, "harnesses", "openclaw", "dist", "index.js"), "// fake dist"); + writeFileSync(join(tmpPkg, "harnesses", "openclaw", "openclaw.plugin.json"), JSON.stringify({ name: "hivemind", version: "1.2.3" })); + writeFileSync(join(tmpPkg, "harnesses", "openclaw", "package.json"), JSON.stringify({ name: "hivemind", version: "1.2.3" })); + mkdirSync(join(tmpPkg, "harnesses", "openclaw", "skills"), { recursive: true }); + writeFileSync(join(tmpPkg, "harnesses", "openclaw", "skills", "hivemind.md"), "skill body"); writeFileSync(join(tmpPkg, "package.json"), JSON.stringify({ version: "1.2.3" })); vi.stubEnv("HOME", tmpHome); @@ -83,8 +83,8 @@ describe("installOpenclaw", () => { }); it("skips optional sources that don't exist (skills, manifest) without throwing", async () => { - rmSync(join(tmpPkg, "openclaw", "skills"), { recursive: true, force: true }); - rmSync(join(tmpPkg, "openclaw", "openclaw.plugin.json")); + rmSync(join(tmpPkg, "harnesses", "openclaw", "skills"), { recursive: true, force: true }); + rmSync(join(tmpPkg, "harnesses", "openclaw", "openclaw.plugin.json")); const { installOpenclaw } = await importInstaller(); expect(() => installOpenclaw()).not.toThrow(); const root = join(tmpHome, ".openclaw", "extensions", "hivemind"); @@ -94,7 +94,7 @@ describe("installOpenclaw", () => { }); it("throws when the dist source is missing (build hasn't run)", async () => { - rmSync(join(tmpPkg, "openclaw", "dist"), { recursive: true, force: true }); + rmSync(join(tmpPkg, "harnesses", "openclaw", "dist"), { recursive: true, force: true }); const { installOpenclaw } = await importInstaller(); expect(() => installOpenclaw()).toThrow(/OpenClaw bundle missing/); }); diff --git a/tests/cli/cli-install-pi-fs.test.ts b/tests/cli/cli-install-pi-fs.test.ts index f97c38f9..f1c0aaf0 100644 --- a/tests/cli/cli-install-pi-fs.test.ts +++ b/tests/cli/cli-install-pi-fs.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -19,13 +19,13 @@ let tmpHome: string; let tmpPkg: string; beforeEach(() => { - tmpRoot = join(tmpdir(), `hm-pi-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tmpRoot = mkdtempSync(join(tmpdir(), "hm-pi-")); tmpHome = join(tmpRoot, "home"); tmpPkg = join(tmpRoot, "pkg"); mkdirSync(tmpHome, { recursive: true }); - mkdirSync(join(tmpPkg, "pi", "extension-source"), { recursive: true }); - writeFileSync(join(tmpPkg, "pi", "extension-source", "hivemind.ts"), "// fake pi extension"); + mkdirSync(join(tmpPkg, "harnesses", "pi", "extension-source"), { recursive: true }); + writeFileSync(join(tmpPkg, "harnesses", "pi", "extension-source", "hivemind.ts"), "// fake pi extension"); writeFileSync(join(tmpPkg, "package.json"), JSON.stringify({ version: "7.7.7" })); vi.stubEnv("HOME", tmpHome); @@ -77,7 +77,7 @@ describe("installPi — cold install", () => { }); it("throws with a 'reinstall the package' hint when the extension source is absent", async () => { - rmSync(join(tmpPkg, "pi", "extension-source"), { recursive: true, force: true }); + rmSync(join(tmpPkg, "harnesses", "pi", "extension-source"), { recursive: true, force: true }); const { installPi } = await importInstaller(); expect(() => installPi()).toThrow(/pi extension source missing/); expect(() => installPi()).toThrow(/Reinstall the @deeplake\/hivemind package/); diff --git a/tests/cli/install-end-to-end.test.ts b/tests/cli/install-end-to-end.test.ts index 6a805c2d..4578e7f5 100644 --- a/tests/cli/install-end-to-end.test.ts +++ b/tests/cli/install-end-to-end.test.ts @@ -313,7 +313,7 @@ describe("installHermes / uninstallHermes", () => { }); // OpenClaw tests intentionally omitted from this end-to-end file: -// `openclaw/dist/` is gitignored (esbuild output, see .gitignore: dist/), +// `harnesses/openclaw/dist/` is gitignored (esbuild output, see .gitignore: dist/), // so it doesn't exist on a fresh CI checkout the way the committed // codex/cursor/hermes bundles do. The dedicated test file // `cli-install-openclaw.test.ts` covers OpenClaw via mock-pkgRoot → diff --git a/tests/codex/codex-capture.test.ts b/tests/codex/codex-capture.test.ts index 9ebc2dff..d0ef4388 100644 --- a/tests/codex/codex-capture.test.ts +++ b/tests/codex/codex-capture.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { sqlStr } from "../../src/utils/sql.js"; -// ── buildSessionPath (mirrors codex/capture.ts and codex/stop.ts) ──────────── +// ── buildSessionPath (mirrors harnesses/codex/capture.ts and harnesses/codex/stop.ts) ──────────── function buildSessionPath(config: { userName: string; orgName: string; workspaceId: string }, sessionId: string): string { return `/sessions/${config.userName}/${config.userName}_${config.orgName}_${config.workspaceId}_${sessionId}.jsonl`; diff --git a/tests/codex/codex-hooks.test.ts b/tests/codex/codex-hooks.test.ts index a42716dc..94b6a4b2 100644 --- a/tests/codex/codex-hooks.test.ts +++ b/tests/codex/codex-hooks.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -const codexRoot = join(process.cwd(), "codex"); +const codexRoot = join(process.cwd(), "harnesses", "codex"); describe("codex hooks.json", () => { const hooks = JSON.parse(readFileSync(join(codexRoot, "hooks", "hooks.json"), "utf-8")); diff --git a/tests/codex/codex-integration.test.ts b/tests/codex/codex-integration.test.ts index 6858e895..59edfabb 100644 --- a/tests/codex/codex-integration.test.ts +++ b/tests/codex/codex-integration.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { execFileSync } from "node:child_process"; import { join } from "node:path"; -const bundleDir = join(process.cwd(), "codex", "bundle"); +const bundleDir = join(process.cwd(), "harnesses", "codex", "bundle"); /** Pipe JSON into a bundle and return parsed stdout. */ function runHook(bundle: string, input: Record<string, unknown>, extraEnv: Record<string, string> = {}): string { @@ -68,7 +68,7 @@ function parseOutput(raw: string): Record<string, unknown> | null { // pushes to both the user-visible entries vec AND the model context vec). // Because of this we deliberately keep `additionalContext` MINIMAL — only a // 1-line status. The full memory tier doc + CLI command list moved into the -// `hivemind-memory` skill (codex/skills/deeplake-memory/SKILL.md), which the +// `hivemind-memory` skill (harnesses/codex/skills/deeplake-memory/SKILL.md), which the // model loads on demand without spamming the terminal every session start. describe("codex integration: session-start", () => { diff --git a/tests/openclaw/auto-recall.test.ts b/tests/openclaw/auto-recall.test.ts index 13ef133d..fcfb1c19 100644 --- a/tests/openclaw/auto-recall.test.ts +++ b/tests/openclaw/auto-recall.test.ts @@ -54,7 +54,7 @@ async function loadPluginWithHooks(): Promise<{ mockApi: ReturnType<typeof buildMockApi>; }> { vi.resetModules(); - const mod = await import("../../openclaw/src/index.js"); + const mod = await import("../../harnesses/openclaw/src/index.js"); const plugin = mod.default as { register: (api: ReturnType<typeof buildMockApi>) => void }; const hooks = new Map<string, HookHandler>(); const mockApi = buildMockApi(hooks); diff --git a/tests/openclaw/hivemind-tools.test.ts b/tests/openclaw/hivemind-tools.test.ts index b7115f57..af61701e 100644 --- a/tests/openclaw/hivemind-tools.test.ts +++ b/tests/openclaw/hivemind-tools.test.ts @@ -69,7 +69,7 @@ type MockTool = { async function loadPluginWithTools() { vi.resetModules(); - const mod = await import("../../openclaw/src/index.js"); + const mod = await import("../../harnesses/openclaw/src/index.js"); const plugin = mod.default as { register: (api: any) => void }; const tools: MockTool[] = []; const mockApi = { @@ -123,7 +123,7 @@ describe("openclaw hivemind tools — registration", () => { it("skips tool registration when host does not expose registerTool", async () => { vi.resetModules(); - const mod = await import("../../openclaw/src/index.js"); + const mod = await import("../../harnesses/openclaw/src/index.js"); const plugin = mod.default as { register: (api: any) => void }; let threw: unknown = null; try { @@ -160,7 +160,7 @@ describe("openclaw hivemind tools — registration", () => { (globalThis as any).__HIVEMIND_SKILL__ = "TEST_SKILL_BODY_CONTENT"; try { vi.resetModules(); - const mod = await import("../../openclaw/src/index.js"); + const mod = await import("../../harnesses/openclaw/src/index.js"); const plugin = mod.default as { register: (api: any) => void }; const onMock = vi.fn(); plugin.register({ @@ -183,7 +183,7 @@ describe("openclaw hivemind tools — registration", () => { it("registers memoryCorpusSupplement when host exposes it", async () => { const supplementMock = vi.fn(); vi.resetModules(); - const mod = await import("../../openclaw/src/index.js"); + const mod = await import("../../harnesses/openclaw/src/index.js"); const plugin = mod.default as { register: (api: any) => void }; plugin.register({ logger: { info: vi.fn(), error: vi.fn() }, diff --git a/tests/openclaw/openclaw-embed-bundle.test.ts b/tests/openclaw/openclaw-embed-bundle.test.ts index 8ddff196..fa83417c 100644 --- a/tests/openclaw/openclaw-embed-bundle.test.ts +++ b/tests/openclaw/openclaw-embed-bundle.test.ts @@ -7,8 +7,8 @@ import { resolve } from "node:path"; * * The shipped openclaw bundle MUST produce real embeddings (not silent * NULL) when the canonical shared daemon is available. The source-level - * wiring is verified at openclaw/src/index.ts, but the actually-shipped - * surface is openclaw/dist/index.js — a slip in the esbuild config + * wiring is verified at harnesses/openclaw/src/index.ts, but the actually-shipped + * surface is harnesses/openclaw/dist/index.js — a slip in the esbuild config * (e.g. an over-aggressive stub-unused-child-process matcher, a * tree-shake of the spawn-impl injection, or a re-introduced INSERT * that omits the embedding column) would silently regress capture @@ -16,7 +16,7 @@ import { resolve } from "node:path"; * the load-bearing strings so the regression is caught at build time. */ -const BUNDLE_PATH = resolve(process.cwd(), "openclaw", "dist", "index.js"); +const BUNDLE_PATH = resolve(process.cwd(), "harnesses", "openclaw", "dist", "index.js"); const SRC = readFileSync(BUNDLE_PATH, "utf-8"); describe("openclaw dist bundle — embeddings wiring", () => { diff --git a/tests/openclaw/setup-command.test.ts b/tests/openclaw/setup-command.test.ts index 8e171340..143ffb95 100644 --- a/tests/openclaw/setup-command.test.ts +++ b/tests/openclaw/setup-command.test.ts @@ -50,7 +50,7 @@ type CommandRegistration = { async function loadSetupCommand(): Promise<CommandRegistration> { vi.resetModules(); - const mod = await import("../../openclaw/src/index.js"); + const mod = await import("../../harnesses/openclaw/src/index.js"); const plugin = mod.default as { register: (api: any) => void }; const commands: CommandRegistration[] = []; plugin.register({ @@ -294,7 +294,7 @@ describe("/hivemind_setup", () => { tools: { profile: "coding", alsoAllow: ["hivemind"] }, }); vi.resetModules(); - const { detectAllowlistMissing } = await import("../../openclaw/src/setup-config.js"); + const { detectAllowlistMissing } = await import("../../harnesses/openclaw/src/setup-config.js"); expect(detectAllowlistMissing()).toBe(true); }); @@ -304,7 +304,7 @@ describe("/hivemind_setup", () => { tools: { profile: "coding", alsoAllow: ["hivemind"] }, }); vi.resetModules(); - const { detectAllowlistMissing } = await import("../../openclaw/src/setup-config.js"); + const { detectAllowlistMissing } = await import("../../harnesses/openclaw/src/setup-config.js"); expect(detectAllowlistMissing()).toBe(false); }); @@ -313,7 +313,7 @@ describe("/hivemind_setup", () => { tools: { profile: "coding", alsoAllow: ["hivemind"] }, }); vi.resetModules(); - const { detectAllowlistMissing } = await import("../../openclaw/src/setup-config.js"); + const { detectAllowlistMissing } = await import("../../harnesses/openclaw/src/setup-config.js"); expect(detectAllowlistMissing()).toBe(false); }); }); diff --git a/tests/pi/pi-extension-source.test.ts b/tests/pi/pi-extension-source.test.ts index 9994f87b..79d19d0f 100644 --- a/tests/pi/pi-extension-source.test.ts +++ b/tests/pi/pi-extension-source.test.ts @@ -19,7 +19,7 @@ import { join } from "node:path"; */ const PI_SRC = readFileSync( - join(process.cwd(), "pi", "extension-source", "hivemind.ts"), + join(process.cwd(), "harnesses", "pi", "extension-source", "hivemind.ts"), "utf-8", ); diff --git a/tests/pi/skillify-spec-drift.test.ts b/tests/pi/skillify-spec-drift.test.ts index b5733ac5..6ab0bb4b 100644 --- a/tests/pi/skillify-spec-drift.test.ts +++ b/tests/pi/skillify-spec-drift.test.ts @@ -15,7 +15,7 @@ import { join } from "node:path"; import { SKILLIFY_COMMANDS } from "../../src/cli/skillify-spec.js"; const PI_SOURCE = readFileSync( - join(process.cwd(), "pi", "extension-source", "hivemind.ts"), + join(process.cwd(), "harnesses", "pi", "extension-source", "hivemind.ts"), "utf-8", ); @@ -27,7 +27,7 @@ const piArrayMatch = PI_SOURCE.match( describe("pi skillify spec drift", () => { it("pi mirror block is present", () => { - expect(piArrayMatch, "PI_SKILLIFY_COMMANDS array literal not found in pi/extension-source/hivemind.ts").toBeTruthy(); + expect(piArrayMatch, "PI_SKILLIFY_COMMANDS array literal not found in harnesses/pi/extension-source/hivemind.ts").toBeTruthy(); }); it("pi mirror has the same number of entries as the canonical spec", () => { diff --git a/tests/scripts/sync-versions.test.ts b/tests/scripts/sync-versions.test.ts index 57dfa803..9e0f8551 100644 --- a/tests/scripts/sync-versions.test.ts +++ b/tests/scripts/sync-versions.test.ts @@ -61,7 +61,7 @@ describe("syncVersions", () => { it("partial-sync state: writes only the targets that drifted", () => { seedFixture("1.2.3", { ".claude-plugin/plugin.json": "1.2.3", // already synced - "codex/package.json": "0.5.0", // drifted + "harnesses/codex/package.json": "0.5.0", // drifted }); const result = syncVersions({ root, log: () => {} }); expect(result.writes).toBeGreaterThan(0); @@ -84,7 +84,7 @@ describe("syncVersions", () => { it("throws when a target file is missing", () => { seedFixture("1.2.3"); - rmSync(resolve(root, "codex/package.json")); + rmSync(resolve(root, "harnesses/codex/package.json")); expect(() => syncVersions({ root, log: () => {} })).toThrow(/codex\/package\.json/); }); diff --git a/tests/shared/agent-model.test.ts b/tests/shared/agent-model.test.ts index 79edb012..7995d2ea 100644 --- a/tests/shared/agent-model.test.ts +++ b/tests/shared/agent-model.test.ts @@ -99,7 +99,7 @@ describe("agentModel — per-agent no-tools dispatch", () => { expect(argVal(calls[0].args, "-m")).toBe("o3"); // the `model ? ["-m", model] : []` present-branch }); - it("rejects a hermes/pi provider override with NO model (the default id wouldn't match)", async () => { + it("rejects a harnesses/hermes/pi provider override with NO model (the default id wouldn't match)", async () => { const { spawnImpl } = fakeSpawn("x"); const env = { HIVEMIND_SKILLOPT_HERMES_PROVIDER: "bedrock" } as unknown as NodeJS.ProcessEnv; await expect(agentModel({ agent: "hermes", role: "judge", bin: "/x/hermes", spawnImpl, env })("S", "U")) diff --git a/tests/shared/embeddings-schema.test.ts b/tests/shared/embeddings-schema.test.ts index e2f673b7..39c22392 100644 --- a/tests/shared/embeddings-schema.test.ts +++ b/tests/shared/embeddings-schema.test.ts @@ -8,8 +8,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; const BUNDLE_DIRS = [ - "claude-code/bundle", - "codex/bundle", + "harnesses/claude-code/bundle", + "harnesses/codex/bundle", ]; function read(path: string): string { diff --git a/tests/shared/skillopt-hook.test.ts b/tests/shared/skillopt-hook.test.ts index 92b34ac7..77a92511 100644 --- a/tests/shared/skillopt-hook.test.ts +++ b/tests/shared/skillopt-hook.test.ts @@ -32,7 +32,7 @@ describe("armSkillOptOnSkillUse", () => { expect(markSkillPending).toHaveBeenCalledWith("s1", "posthog--kamo", "tu2"); }); - it("arms on a SHELL command that reads SKILL.md (codex/hermes style — path in the command)", () => { + it("arms on a SHELL command that reads SKILL.md (harnesses/codex/hermes style — path in the command)", () => { armSkillOptOnSkillUse("s1", "Bash", { command: 'cat "/home/u/.agents/skills/posthog--kamo/SKILL.md"' }, "tu3"); expect(markSkillPending).toHaveBeenCalledWith("s1", "posthog--kamo", "tu3"); }); diff --git a/vitest.config.ts b/vitest.config.ts index 2db42b1e..f1536847 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "vitest/config"; // Root vitest config. `npm test` runs `vitest run` from the repo root, so -// this is the file that actually gets picked up. The one in claude-code/ +// this is the file that actually gets picked up. The one in harnesses/claude-code/ // is a historical leftover and is not used by the root test script. // // Coverage thresholds are enforced per-file on the files touched by each