diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c0ca74..a06fc61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ jobs: test: name: Test runs-on: ubuntu-latest + env: + npm_config_fetch_retries: 5 + npm_config_fetch_retry_mintimeout: 20000 + npm_config_fetch_retry_maxtimeout: 120000 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 515f76c..a5166a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,10 @@ jobs: npm-release: name: npm release runs-on: ubuntu-latest + env: + npm_config_fetch_retries: 5 + npm_config_fetch_retry_mintimeout: 20000 + npm_config_fetch_retry_maxtimeout: 120000 steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 59aba14..99030e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ .lazybrain/ +.claude/ *.tsbuildinfo .env *.env @@ -45,3 +46,6 @@ scripts/check-budget.sh # Local workflow doc (user-facing, but not for public repo — keep local) HOW_TO_WORK_WITH_TRAE.md +.gstack/ +.gitnexus +.gitnexus.* diff --git a/CHANGELOG.md b/CHANGELOG.md index a29029f..2e95e0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,138 +1,9 @@ # Changelog -All notable changes to this project will be documented in this file. +## [v1.5.0] -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [v1.5.0] - 2026-04-27 - -### Added -- Persistent statusline with active (bold) vs dormant (dim) visual distinction, always visible in combined HUD mode. -- Complete UI/UX redesign: unified design system, dark mode support, Chinese-first scrollable single-page layout. -- Chinese admin panel with 6 sections: status overview, live route tester, tool browser, API config editor, system diagnostics, and setup guide. -- Inline API config editing (compile/embedding/secretary LLM settings, API keys, routing engine) with save-to-config. -- `POST /api/config` endpoint to write configuration from the web UI (14 whitelisted keys). -- `GET /api/diagnostics` endpoint returning hook runtime stats, recent events, graph status, and embedding cache health. -- Tiny gate hook now runs lightweight tag-layer matching and injects real results with scores (🟢🟡⚪), tool names, descriptions, and personality roasts. -- Auto language detection (zh/en) for hook-injected routing suggestions. -- Project CLAUDE.md with model-friendly install instructions. - -### Changed -- Search API (`/api/search`) now returns all nodes when no query or filter is specified, fixing the empty tools display. -- Lab page redesigned to match the unified design system. - -### Fixed -- JavaScript syntax error in setup guide cmd strings due to unescaped newlines in TypeScript template literals. -- Config fields displaying as "未配眮" (not configured) due to field name mismatch in renderConfig(). -- `autoThreshold` validation now rejects NaN values. -- `POST /api/config` now checks Origin header for defense-in-depth. -- Tiny gate hook now writes `last-match.json` so the statusline shows real match results instead of "建议路由". -- Platform-specific native packages moved to `optionalDependencies` for cross-platform CI compatibility. - -## [v1.4.5] - 2026-04-26 - -### Added -- `RouteSpec` schema version, `no_route_needed` mode, `tokenStrategy`, and route rationale fields for stable cross-surface routing. -- `lazybrain prompt "" --target claude|codex|cursor|generic` with explicit `--copy` clipboard support. -- Read-only MCP stdio server via `lazybrain mcp --stdio`, exposing `lazybrain.route`, `lazybrain.search`, `lazybrain.skill_card`, and `lazybrain.combos`. -- Privacy-preserving route counters through `lazybrain route stats`. -- `lazybrain hook status --json` for runtime diagnostics including skip reason, breaker state, active/hung runs, and p95 duration. - -### Changed -- The default Claude hook is now a tiny gate: it performs a fast complexity/vagueness/risk check and injects only a short reminder to call `lazybrain.route`. -- `/api/route` now uses the same history/profile inputs as CLI routing and enforces query/body size limits. -- Public docs now position MCP and prompt output as the main low-intrusion route surfaces, with hook as a reminder gate. - -### Security -- MCP tools are read-only, do not execute skills, do not install hooks, do not return agent bodies, and do not read transcripts. -- Hook route telemetry stores only hashes and compact metadata, not raw prompts. - -## [v1.4.0] - 2026-04-25 - -### Added -- Advisory Route Plan orchestrator via `lazybrain route ""`, `--json`, and `--target generic|claude|codex|cursor`. -- Stable `RouteSpec` output with intent, scenario, skills, workflow, context needed, guardrails, verification, done conditions, adapter prompts, warnings, and clarification questions. -- Optional SKILL.md frontmatter schema fields: `useWhen`, `avoidWhen`, `inputs`, `workflow`, `verification`, `doneWhen`, `contextNeeded`, and `guardrails`. -- Built-in combo templates for frontend pages, redesigns, CEO dashboards, public install docs, regression review, stuck-runtime debugging, and public release audit. -- `lazybrain combos [category]` for read-only combo discovery. -- Verification catalog for UI screenshots, dashboard operating questions, docs readability, code checks, hook dry-run, rollback, privacy scan, and package dry-run. -- `POST /api/route` and GUI Try Router Route Plan display. - -### Changed -- Route planning stays outside the matcher; `match()` remains retrieval-only while the orchestrator builds execution guidance. -- README and README_CN now document RouteSpec, combo templates, advisory-only behavior, and schema metadata. - -### Security -- Route planning does not execute skills, install hooks, read transcripts, return agent bodies, or write Claude/Codex/Cursor configuration. - -## [v1.3.0] - 2026-04-25 - -### Added -- Local Web GUI via `lazybrain ui`, with Overview, Try Router, Skill DB, Hook Safety, Lab, Health, Troubleshooting, and Settings pages. -- Read-only GUI/status APIs: `/`, `/ui`, `/api/status`, `/api/health`, `/api/stats`, `/api/search`, `/api/embeddings/status`, and Lab API aliases. -- Explicit action APIs for `POST /api/test` and `POST /api/embeddings/rebuild` with confirmation gates. -- CLI status homepage as the default `lazybrain` output. -- `lazybrain api test` for compile LLM, secretary LLM, and embedding checks without printing keys. -- `lazybrain embeddings status` and `lazybrain embeddings rebuild --yes` with temp-file atomic cache writes. -- Public audit gate through `npm run audit:public`, PR template, optional Codex review guide, and GitHub release workflow. - -### Changed -- `lazybrain --version`, `/health`, `/api/health`, package metadata, and changelog now share one package-version source. -- CI keeps a stable required `Test` check while covering Node 18, 20, and 22, package dry-run, public privacy scan, hook tests, and Lab/server smoke. -- README and README_CN now document GUI usage, API testing, embedding cache rebuild, release gates, and bug recovery. - -### Security -- Public audit blocks private paths, local planning docs, personal email markers, token-like secrets, private runtime directory markers, and internal workspace-name leaks. -- Root `AGENTS.md` is no longer tracked in the public repository. -- GUI v1 does not install hooks, read Claude transcripts, return agent body text, or write `.claude/settings.json`. - -## [v1.2.0] - 2026-04-25 - -### Added -- Non-install LazyBrain Lab at `/lab` for visual recommendation testing, agent mapping, team gating, token strategy, and hook readiness. -- Agent inventory scanner for project, user, and plugin agents using metadata only. -- Trusted hook install workflow with dry-run plan, automatic backups, rollback, readiness checks, and global-install confirmation. -- Advisory team model guidance, runtime adapters, and subagent prompt suggestions. - -### Changed -- Documentation now recommends scan, offline compile, ready check, Lab preview, hook plan, then project-scoped install. -- Hook docs now separate implemented behavior from planned capabilities and clarify semantic fallback behavior. -- `lazybrain ready` now blocks when hook breaker state, hung records, or host load would make the hook fail closed. -- README and README_CN now include v1.2.0 release positioning, skill/agent metadata coverage, daily usage, and troubleshooting guidance. - -### Security -- Redact sensitive config values in CLI output. -- Lab and hook plan responses avoid agent body text, Claude private transcripts, local home paths, and statusline secret parameters. -- Project-scope runtime guard now canonicalizes symlinked workspace paths before comparing cwd. -- Remove internal agent workflow protocol documents from the public repository. - -## [v1.1.0] - 2026-04-23 - -### Added -- Add baseline token cost calculation for accurate token savings in session statistics and dashboard. - -## [v1.0.2] - 2026-04-20 - -### Added -- Project-scoped hook install metadata and workspace `cwd` guard so LazyBrain only runs inside the intended repo by default -- Hook runtime registry, active run inspection, and breaker diagnostics via `lazybrain doctor`, `lazybrain hook ps`, and `lazybrain hook clean` - -### Changed -- Hardened hook runtime safety with concurrency limits, hung/stale run handling, overload breaker checks, and fail-closed scope behavior when install metadata is missing -- `doctor --fix` now only repairs LazyBrain-owned state and refuses to silently rebind a missing project scope -- `hook status` and startup diagnostics now surface scope, active hooks, hung hooks, breaker state, and confirm that LazyBrain does not participate in `Stop` -- Documentation updated to reflect the sidecar-agent lifecycle, project-scoped hook behavior, and CLI-first runtime guidance - -## [v1.0.0] - 2026-04-19 - -### Added -- Step 1: Cleanup embedding dead code and fixup decision type identification quality -- Step 2: Decision type identifier for classifying user intents -- Step 3: Team recommender for intelligent agent team formation -- Step 4: Thinking trigger for proactive tool suggestions -- Step 5: Duplicate detector for identifying redundant tools/skills -- Step 6: HTTP API server for desktop UI integration -- Step 7: Real usage data tracking for analytics and improvement +- Kept the verified route, MCP, compile, embeddings, ready, statusline, status, and diagnostics path. +- Replaced the unfinished web console with a compact Workbench for status, diagnostics, route, compile, embeddings, and API tests. +- Removed unfinished choice preference, route adoption, route regression, public jobs, repairs, doctor-fix, and config-test/schema surfaces. +- Removed unpublished planning documents and the bundled Cytoscape asset from the public package. +- Kept RouteSpec `1.5.0` output stable for CLI, HTTP, and MCP consumers. diff --git a/CLAUDE.md b/CLAUDE.md index 0837f95..6cda4aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,3 +81,47 @@ Key routing rules: - Ship/deploy/PR → invoke /ship or /land-and-deploy - Save progress → invoke /context-save - Resume context → invoke /context-restore + + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **lazy-brain** (3580 symbols, 10213 relationships, 267 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/lazy-brain/context` | Codebase overview, check index freshness | +| `gitnexus://repo/lazy-brain/clusters` | All functional areas | +| `gitnexus://repo/lazy-brain/processes` | All execution flows | +| `gitnexus://repo/lazy-brain/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/README.md b/README.md index 81e7a71..b69c3df 100644 --- a/README.md +++ b/README.md @@ -1,704 +1,81 @@ -
+# LazyBrain -# 🧠 LazyBrain +Semantic capability router for local AI coding agents. -**Semantic Skill Router / Sidecar Agent for AI Coding Assistants** -**面向 AI 猖码助手的语义路由噚 / 附属性智胜䜓** +## Supported Surface -[![CI](https://github.com/papperrollinggery/lazy-brain/actions/workflows/ci.yml/badge.svg)](https://github.com/papperrollinggery/lazy-brain/actions/workflows/ci.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -[![Node](https://img.shields.io/badge/node-%E2%89%A518-brightgreen.svg)](https://nodejs.org) - -> A sidecar agent that turns a fragmented toolbelt into an intent-aware execution layer. -> Scan capabilities, compile a graph, route non-trivial work, and stay out of the `Stop` lifecycle. - -[English](README.md) | [䞭文文档](README_CN.md) - ---- - -
- -## Current Release - -Current version: **v1.4.5** - -Release position: **low-intrusion routing beta**. This version hardens `RouteSpec`, adds a read-only MCP server, adds copyable target prompts, and changes the Claude hook into a tiny gate. The hook only reminds the main model to call LazyBrain for non-trivial work; full recommendations stay in `lazybrain route`, `/api/route`, MCP, GUI, or explicit prompt output. - -## Overview - -Modern coding environments accumulate a large number of capabilities: - -- local skills -- project agents -- CLI commands -- MCP-backed tools -- orchestration modes - -The real bottleneck is not capability supply. It is **capability recall and execution routing**. - -LazyBrain sits beside the primary coding model as a **sidecar agent**: - -- it scans the local capability surface -- compiles a knowledge graph over those capabilities -- builds advisory route plans for the main model -- exposes the same route contract through CLI, HTTP API, MCP, and prompt output -- avoids competing for `Stop` hooks with memory and notification plugins - -The result is a system where the user says what they want, and the router decides which capability should be brought into context. - -## Why It Exists - -Without a routing layer, advanced AI coding setups degrade in predictable ways: - -- installed tools go unused because nobody remembers exact names -- cross-language queries fail (`䞭文需求` vs English capability names) -- users over-trigger expensive modes because the surface is too fragmented -- multiple plugins overlap, but no layer explains which one should act - -LazyBrain addresses that by turning a loose toolbelt into a structured capability layer. - -``` -You type: "垮我审查这䞪 PR" -LazyBrain: → /review-pr (92%) | /critic (78%) | /santa-loop (71%) - → Route Plan: use code review + regression checks + test evidence -``` - -## Core Properties - -- **Intent-first routing**: users describe goals, not command names -- **Capability-agnostic**: covers skills, agents, commands, modes, and hooks -- **Bilingual matching**: Chinese and English queries are both first-class -- **Local-first pipeline**: scan, graph, wiki, and tag layers work from local artifacts -- **Low-intrusion lifecycle**: project-scoped `UserPromptSubmit` tiny gate only; no `Stop`, no default `SessionStart` - -## How It Works / 工䜜方匏 - -LazyBrain has three phases: **Scan → Compile → Route**. Routing can be tested in Lab first, then installed as a Claude Code hook when you are ready. - -``` - ┌──────────┐ ┌──────────┐ ┌──────────────┐ - │ scan │────▶│ compile │────▶│ route / lab │ - │ Discover │ │ LLM tags │ │ preview or │ - │ tools │ │ + graph │ │ MCP / prompt │ - └──────────┘ └──────────┘ └──────────────┘ - │ │ │ - local capability graph.json Lab preview - surfaces wiki/ or UserPromptSubmit - MCP + built-ins relations tiny hook gate -``` - -1. **scan** — Discovers all skills, agents, MCP tools, and built-in commands - **scan**扫描本地 skill、agent、MCP 工具和内眮呜什 -2. **compile** — Builds the graph offline, or uses an LLM when configured for richer tags and relationships - **compile**犻线构建囟谱配眮 LLM 后可生成曎䞰富的标筟和关系 -3. **route** — Returns an advisory `RouteSpec`; hook/MCP/prompt are just delivery surfaces - -## Public-Safe Workflow - -Default flow for public users: +Core commands: ```bash -lazybrain scan -lazybrain compile --offline +lazybrain route "review this change" --target codex --brief +lazybrain route "review this change" --target claude --json +lazybrain route dogfood --target claude lazybrain ready -lazybrain ui -lazybrain route "review this PR" -lazybrain prompt "review this PR" --target claude -lazybrain hook plan -lazybrain hook install -``` - -Safety defaults: - -- Lab does not install hooks and does not write `.claude/settings.json` -- `hook plan` is dry-run only -- `hook install` defaults to project scope and creates a backup first -- global install requires `lazybrain hook install --global --yes` -- LazyBrain does not use `Stop` as a product lifecycle -- third-party hooks and HUD/statusline entries are preserved by default -- GUI v1 does not install hooks directly; it shows status, previews, and CLI fallback commands -- `lazybrain route` is advisory only; it does not execute skills or write target CLI config -- `lazybrain mcp` is read-only and does not return agent bodies or private transcripts -- installed hook only injects a short reminder: `Consider calling lazybrain.route for skill routing, context reduction, and verification planning.` - -## What Counts as a Skill / Agent / Capability - -LazyBrain treats the local AI tool surface as **capabilities**. A capability can be: - -- a skill directory with `SKILL.md` -- a Claude/Agent Agency agent markdown file -- a command markdown file -- a mode, hook, or plugin-provided entry that appears in scanned paths - -For skills, LazyBrain reads: - -- `name`, `description`, `trigger`, `triggers`, and `origin` from frontmatter when present -- optional route schema fields: `useWhen`, `avoidWhen`, `inputs`, `workflow`, `verification`, `doneWhen`, `contextNeeded`, and `guardrails` -- the first useful body paragraph as a fallback description -- the parent directory name as a fallback skill name - -For agents, the Lab inventory only exposes public metadata: - -- `name` -- `description` -- `scope` -- `source` -- `model` -- `tools` - -It does not return agent body text, Claude private transcripts, or conversation history. During scan/compile, LazyBrain parses local markdown files to build a capability graph; it does not execute the skill or agent. - -Recommended skill shape: - -```markdown ---- -name: code-review -description: Review code for correctness, regressions, maintainability, and missing tests. -triggers: - - review code - - 审查代码 -useWhen: ["review code changes", "check regression risk"] -workflow: [{"title":"Inspect changed files"},{"title":"Prioritize behavioral findings"}] -verification: [{"title":"Run tests","command":"npm test"}] -doneWhen: ["Findings are grounded in file evidence or tests pass"] -contextNeeded: ["diff or branch", "expected behavior"] -guardrails: [{"title":"Lead with bugs and regressions","strength":"strict"}] ---- - -Use this skill when the user asks for a focused engineering review. -``` - -If a skill does not appear in results, check that it is under a scanned skill path, has a `SKILL.md`, and includes a clear `name` or `description`. - -## Matching Engine / 匹配匕擎 - -When you type a prompt, LazyBrain uses the currently implemented routing layers in order: - -``` - Prompt: "垮我审查这䞪 PR" - │ - â–Œ - ┌─────────────────────────────────────────────────┐ - │ Layer 0: Manual Alias │ - │ Exact match? → Return immediately │ - │ e.g. "review" → /review-pr │ - └─────────────────┬───────────────────────────────┘ - │ No match - â–Œ - ┌─────────────────────────────────────────────────┐ - │ Layer 1: Tag Matching │ - │ CJK bigram + cross-language bridge │ - │ "审查" → expanded to ["review", "audit"] │ - │ <1ms, fully offline │ - └─────────────────┬───────────────────────────────┘ - │ Low confidence - â–Œ - ┌─────────────────────────────────────────────────┐ - │ Layer 2: Semantic / Hybrid │ - │ Embedding cache required │ - │ Falls back with warnings when cache is missing │ - └─────────────────┬───────────────────────────────┘ - │ Build route contract - â–Œ - ┌─────────────────────────────────────────────────┐ - │ RouteSpec │ - │ route_plan / needs_clarification / │ - │ no_route_needed │ - │ token strategy + verification guidance │ - └─────────────────────────────────────────────────┘ -``` - -**Offline capable**: manual aliases and tag matching work without any network connection. `semantic` / `hybrid` requires embedding config plus `graph.embeddings.*` cache; when cache is missing, LazyBrain falls back to the lower layers and reports a warning. - -The default hook does not run Secretary or inject full recommendations. Secretary/API checks are explicit through `lazybrain api test`; route planning stays advisory and compact. - -**支持犻线**手工别名和 tag-layer 䞍需芁眑络`semantic` / `hybrid` 需芁 embedding 配眮和 `graph.embeddings.*` 猓存猓存猺倱时䌚降级并给出 warning。 - -## Implemented vs Planned - -| Area | Current behavior | Notes | -|------|------------------|-------| -| Offline routing | Manual alias + tag/CJK bridge | Works without API keys | -| Semantic / hybrid | Uses embedding cache when configured | Falls back with warnings when cache is missing | -| Route plan | `lazybrain route` returns v1.4.5 `RouteSpec` | Includes `route_plan`, `needs_clarification`, and `no_route_needed` | -| MCP | `lazybrain mcp --stdio` exposes read-only route/search/card/combo tools | Does not write target CLI config or return agent bodies | -| Manual prompt | `lazybrain prompt` renders target-specific copyable guidance | Useful when MCP is not configured | -| Combo templates | Built-in high-frequency orchestration templates | `lazybrain combos [category]` is read-only | -| Hook install | Project scope tiny gate, dry-run plan, backup, rollback | Global install requires `--global --yes`; hook injects only a short reminder | -| Lab | Built-in fixtures, local agent metadata, team gate, token strategy, hook readiness | Does not read Claude transcripts or install hooks | -| Team guidance | Advisory model split, runtime adapters, subagent prompts | Main model or user keeps final decision | -| Auto-alias | Suggest/read-only path today | Fully automatic promotion is still planned | - -## Continuous Adaptation - -LazyBrain can learn from usage patterns without treating every planned capability as already mature: - -``` - ┌───────────────────────────────────────────────┐ - │ Usage History │ - │ "审查代码" → /code-review (accepted) │ - │ "审查代码" → /wiki (rejected!) │ - │ "审查代码" → /code-review (accepted) │ - └───────────────┬───────────────────────────────┘ - │ distill - â–Œ - ┌───────────────────────────────────────────────┐ - │ Rejection Learning │ - │ wiki was rejected for "审查代码" queries │ - │ → auto-deprioritize wiki for similar queries │ - ├──────────────────────────────────────────────── - │ Auto-Alias Generation (planned) │ - │ repeated choices can become shortcuts │ - │ this is not treated as mature yet │ - ├──────────────────────────────────────────────── - │ Tag Evolution │ - │ Users search "审查代码" but tag is only │ - │ "review" → evolve adds "审查" as a new tag │ - ├──────────────────────────────────────────────── - │ Task Chain Prediction │ - │ After using /review-pr → suggest /refactor │ - │ (within current session only) │ - └───────────────────────────────────────────────┘ -``` - -## Wiki and Graph Outputs - -`lazybrain compile` generates runtime artifacts under `~/.lazybrain/`. They are local machine outputs, not project-source files committed to this repo. - -``` -~/.lazybrain/wiki/ -├── index.md -├── development.md -├── operations.md -├── orchestration.md -└── ... -``` - -Important details: - -- category files are generated from a **fixed category vocabulary** plus dynamic local classification -- the number of generated category files depends on what actually exists in your local graph -- counts in examples are **illustrative**, not guaranteed project constants -- wiki pages cover **capabilities**, not just “tools”; that includes: - - skills - - agents - - commands - - other capability kinds that appear in the graph - -Current wiki output is category-centric: - -- `index.md` links to category pages -- `kinds.md` groups capabilities by kind (`skill / agent / command / mode / hook`) -- `origins.md` groups capabilities by source/origin (`local / ECC / OMC / plugin / external ...`) -- each category page groups entries under `Skills`, `Agents`, `Commands`, and `Other` - -So agents and commands are not missing; they are currently surfaced inside category pages rather than separate top-level indices. - -## Quick Start / 快速匀始 - -**Prerequisites / 前眮条件**: Node.js ≥ 18 - -```bash -# Install from GitHub / 从 GitHub 安装 -git clone https://github.com/papperrollinggery/lazy-brain.git -cd lazy-brain -npm install -npm run build -npm link # makes the `lazybrain` / `lb` commands global - -# Verify / 验证 -lazybrain --version -``` - -```bash -# Setup / 初始化 -lazybrain scan # Scan local tools -lazybrain compile --offline # Build tag-layer graph without API key -lazybrain ready # Check graph, hook, HUD, and semantic readiness - -# Non-install visual check / 非安装匏可视化检查 -lazybrain ui # Opens http://127.0.0.1:18450/ -lazybrain route "review this PR" # Advisory execution plan, no writes -# lazybrain ui --no-open -# open http://127.0.0.1:18450/lab - -# Install only after reviewing the plan / 审查预挔后再安装 -lazybrain hook plan # Preview settings changes, no writes -lazybrain hook install # Install project-scoped Claude Code hook - -# Explicit global install / 星匏党局安装 -# lazybrain hook install --global --yes - -# Roll back latest LazyBrain hook backup / 回滚最近䞀次 Hook 倇仜 -# lazybrain hook rollback -``` - -After hook install, prompts inside the recorded project workspace pass through the tiny gate. Complex, vague, or high-risk prompts get a short reminder to call LazyBrain; full plans are pulled through CLI/API/MCP. - -安装 hook 后圓前记圕的项目工䜜区只经过 tiny gate。倍杂、暡糊或高风险任务䌚收到短提醒完敎计划由 CLI/API/MCP 拉取。 - -`lazybrain hook install` writes project `.claude/settings.json` by default and creates a LazyBrain backup first. Global install is refused unless `--global --yes` is present. - -## Daily Usage - -Use these commands for the normal public flow: - -```bash -lazybrain --version # Confirm the installed version -lazybrain scan # Refresh local capabilities -lazybrain compile --offline # Build graph without an API key -lazybrain match "review this PR" # Test recommendation quality in terminal -lazybrain route "review this PR" # Build advisory RouteSpec plan -lazybrain prompt "review this PR" --target claude -lazybrain mcp status # Check MCP readiness -lazybrain ready # Check graph, hook, HUD, and semantic readiness -lazybrain ui # Open the local GUI -lazybrain hook plan # Preview hook changes -lazybrain hook install # Install project-scoped hook -``` - -Use the GUI before hook install when you want a visual check: - -```bash -lazybrain ui -open http://127.0.0.1:18450/lab -``` - -Use rollback when hook behavior is not what you expected: - -```bash -lazybrain hook rollback -lazybrain hook status -``` - -## Configuration / 配眮 - -```bash -# Optional / 可选LLM compileOpenAI-compatible -lazybrain config set compileApiBase https://api.siliconflow.cn/v1 -lazybrain config set compileApiKey -lazybrain config set compileModel Qwen/Qwen3-235B-A22B-Instruct-2507 - -# Optional / 可选semantic / hybrid matching -lazybrain config set embeddingApiBase https://api.siliconflow.cn/v1 -lazybrain config set embeddingApiKey -lazybrain config set embeddingModel BAAI/bge-m3 -lazybrain config set engine hybrid -lazybrain api test # Explicit external API check -lazybrain embeddings status # Read-only cache coverage check -lazybrain embeddings rebuild --yes # Writes ~/.lazybrain/graph.embeddings.* - -# Optional / 可选Secretary LLM可回退到 compile key -lazybrain config set secretaryApiKey -lazybrain config set secretaryModel Qwen/Qwen2.5-7B-Instruct - -# UI mode / 界面暡匏 -lazybrain config set mode auto # Auto-inject (silent) -# lazybrain config set mode ask # Show selection UI -``` - -Config file / 配眮文件`~/.lazybrain/config.json` - -`lazybrain config show` redacts API keys in terminal output. - -## Commands / 呜什 - -### Matching / 匹配 - -```bash -lazybrain match "重构这段代码" # Find matching tools -lazybrain find "代码审查" # Alias for match -lazybrain route "把后台改成 CEO dashboard" -lazybrain route "review this PR" --target codex -lazybrain route "review this PR" --json -lazybrain route stats -lazybrain prompt "review this PR" --target claude -lazybrain prompt "review this PR" --target codex --copy +lazybrain ready --release +lazybrain doctor --json +lazybrain embeddings status +lazybrain embeddings rebuild --yes lazybrain mcp status -lazybrain mcp --stdio -lazybrain combos frontend ``` -`lazybrain route` upgrades raw matches into an advisory `RouteSpec`: `schemaVersion`, `mode`, scenario, skills, token strategy, context needed, workflow, guardrails, verification, done conditions, and a target-specific prompt style for `generic`, `claude`, `codex`, or `cursor`. - -Route modes: - -- `route_plan`: use LazyBrain's top-K compact skill plan. -- `needs_clarification`: ask clarifying questions before loading skills. -- `no_route_needed`: handle the task directly; do not spend routing context. - -`lazybrain prompt` renders the same plan as a copyable target prompt. `lazybrain mcp --stdio` exposes read-only tools: `lazybrain.route`, `lazybrain.search`, `lazybrain.skill_card`, and `lazybrain.combos`. These surfaces do not execute skills, install hooks, read transcripts, return agent bodies, or write Claude/Codex/Cursor configuration. - -### Management / 管理 +Build and refresh local capability data: ```bash -lazybrain scan # Re-scan tools -lazybrain compile # Recompile knowledge graph -lazybrain compile --force # Force full recompile -lazybrain compile --offline # Compile without LLM (tag-based only) -lazybrain list # List all tools -lazybrain stats # Graph statistics -lazybrain ready # Check graph, hook, HUD, and semantic readiness -lazybrain ui # Start local Web GUI -lazybrain server --daemon # Start local API server directly +lazybrain scan +lazybrain compile --offline +lazybrain compile --with-relations ``` -### Local Web GUI / 本地 GUI +Local HTTP workbench: ```bash -lazybrain ui +lazybrain server lazybrain ui --no-open -lazybrain ui --port 18451 -lazybrain ui status -lazybrain ui stop -``` - -GUI entrypoints: - -- `GET /` and `GET /ui` — local status GUI -- `GET /lab` — non-install recommendation Lab -- `GET /api/status` — readiness, graph, routing, hook, API, embedding, agent, and server status -- `POST /api/route` — advisory route plan; no execution and no target CLI config writes -- `POST /api/test` — explicit API test only after user action -- `POST /api/embeddings/rebuild` — requires `{ "confirm": "rebuild" }` - -GUI v1 is status-first: it does not read Claude transcripts, return agent body text, install hooks, or write `.claude/settings.json`. - -### Lab / Non-install visual testing - -```bash -lazybrain server --daemon -open http://127.0.0.1:18450/lab -``` - -The Lab uses built-in fixtures to inspect matching quality, team gating, token strategy, hook readiness, and Claude/Agent Agency subagent mapping without installing hooks or writing Claude settings. - -Lab endpoints: - -- `GET /lab` — self-contained local HTML page -- `GET /lab/fixtures` — built-in evaluation cases -- `GET /lab/agents` — local agent metadata only: name, description, scope, source, model, tools -- `POST /lab/evaluate` — match, team guidance, runtime adapters, token strategy, hook readiness, and warnings - -The agent inventory scanner does not return agent body text and does not read Claude private transcripts. - -### Evolution / 挔化从䜿甚䞭孊习 - -```bash -lazybrain suggest-aliases # Show suggested aliases (read-only) -lazybrain evolve # Learn new tags from usage patterns -lazybrain evolve --dry-run # Preview what evolve would do -lazybrain evolve --rollback # Undo last evolution -``` - -### Hook / Hook 安装 - -```bash -lazybrain hook plan # Preview hook install, no writes -lazybrain hook install # Install Claude Code hook -lazybrain hook install --global --yes # Explicit confirmed global install -lazybrain hook rollback # Restore latest LazyBrain hook backup -lazybrain hook uninstall # Uninstall hook -lazybrain hook status # Check hook status -lazybrain hook status --json # Machine-readable runtime status -lazybrain hook ps # Show active hook runs -lazybrain hook clean # Clean stale hook records -lazybrain doctor # Diagnose LazyBrain runtime state -lazybrain doctor --fix # Repair LazyBrain-only state drift -lazybrain doctor --all # Report project and global scopes, no fix ``` -### Hook Safety / Hook 安党暡型 +Stable local API: -- `lazybrain hook install` now defaults to **project scope** -- `lazybrain hook plan` previews the target settings path, lifecycle hooks, third-party hooks, statusline handling, install-state path, and risk conclusion without writing `.claude/settings.json` or `~/.lazybrain/*` -- `lazybrain hook install` creates a LazyBrain backup before writing settings -- `lazybrain hook rollback` restores only files that LazyBrain backed up -- `lazybrain hook install --global` is refused unless `--yes` is also present -- runtime tiny gate only applies inside the recorded workspace root -- if a prompt comes from another cwd, LazyBrain returns no-op immediately -- the default hook does not run Secretary, wiki-card generation, full matching output, or agent/team expansion -- high load, concurrency limit, breaker, missing graph, and non-`UserPromptSubmit` events fail closed with no user-facing delay -- `Stop` is still outside the product lifecycle -- third-party hooks and mixed hook entries are preserved -- existing third-party HUD/statusline is skipped by default; `--statusline` combines, `--replace-statusline` replaces -- `doctor --fix` only repairs **LazyBrain's own state** - - hook registration normalization - - stale runtime record cleanup - - breaker reset - - install metadata repair when metadata already exists -- `doctor --fix` does **not** modify third-party plugins or system services -- `doctor --all --fix` is disabled to avoid cross-scope writes +- `GET /api/status` +- `GET /api/routes` +- `GET /api/diagnostics` +- `POST /api/route` +- `POST /api/compile` +- `GET /api/compile/status` +- `GET /api/embeddings/status` +- `POST /api/embeddings/rebuild` +- `GET /api/config` +- `POST /api/config` +- `POST /api/test` -### Uninstall and Rollback / 卞蜜䞎回滚 +## Route Output -```bash -lazybrain hook uninstall # Remove LazyBrain hook registration -lazybrain hook rollback # Restore latest LazyBrain backup -lazybrain hook rollback --to # Restore a specific backup timestamp -``` - -Rollback restores only files that were captured by LazyBrain backups. It does not delete third-party hook files. - -### What It Will Not Do / 默讀䞍䌚做什么 - -- no global hook install by default -- no `Stop` lifecycle dependency -- no third-party hook deletion -- no third-party HUD overwrite by default -- no config writes during `hook plan` -- no silent semantic claim when embedding cache is missing -- no full skill body injection from the hook - -## Troubleshooting +`lazybrain route` returns RouteSpec `1.5.0`: mode, intent, matched capability, route plan, guardrails, verification, done conditions, target-specific advisory prompt, and a deterministic recommended choice. The output is advisory and does not execute tasks. -| Symptom | Check | Fix | -|---------|-------|-----| -| `lazybrain ready` says graph is missing | `~/.lazybrain/graph.json` does not exist | Run `lazybrain scan && lazybrain compile --offline` | -| GUI or Lab page does not open | Server is not running or port is different | Run `lazybrain ui`, or `lazybrain ui --port 18451` | -| Lab shows no agents | No readable agent metadata found | Add project agents under `.claude/agents/` or user agents under `~/.claude/agents/`, then refresh Lab | -| `hook plan` reports `needs_attention` because of LazyBrain in `Stop` | Older LazyBrain hook registration remains | Review the plan; `lazybrain hook install` will clean LazyBrain-owned `Stop` entries | -| `hook install --global` fails | Global install requires explicit confirmation | Use `lazybrain hook install --global --yes` only if you want every Claude project affected | -| Hook is installed but no recommendation appears | v1.4.5 hook is a tiny gate, not a full recommender | Run `lazybrain hook status --json`; test the full plan with `lazybrain route ""` | -| Main model ignores LazyBrain | MCP is not configured or the task looked trivial | Use `lazybrain prompt "" --target claude`, or configure `lazybrain mcp --stdio` in the client | -| Hook seems stuck or returns no output after a long run | Runtime breaker or stale record may be active | Run `lazybrain hook ps`, then `lazybrain hook clean`, then `lazybrain ready` | -| Third-party HUD/statusline is present | LazyBrain skips it by default | Use `lazybrain hook install --statusline` to combine, or `--replace-statusline` only when you intentionally want replacement | -| `lazybrain api test` reports 401 | API key is invalid or not accepted by the configured base/model | Reset the key with `lazybrain config set ...ApiKey ` and rerun `lazybrain api test` | -| semantic/hybrid does not improve matches | Embedding config or cache is missing/stale/dimension-mismatched | Run `lazybrain embeddings status`; rebuild with `lazybrain embeddings rebuild --yes` after config is correct | -| A skill is missing from results | The skill path or metadata is incomplete | Ensure the skill has `SKILL.md` with `name` or `description`, then run `lazybrain scan` | +## MCP -Safe recovery commands: +`lazybrain mcp --stdio` exposes read-only tools for route planning, capability search, skill cards, and combo templates. Check readiness with: ```bash -lazybrain ready -lazybrain hook status -lazybrain hook status --json -lazybrain hook ps -lazybrain hook clean -lazybrain hook rollback -lazybrain doctor -lazybrain api test -lazybrain embeddings status -lazybrain route stats lazybrain mcp status ``` -`doctor --fix` only repairs LazyBrain-owned state in the current scope. `doctor --all --fix` is intentionally disabled. - -### Smoke Test / 冒烟测试 - -Validates the full install path from fresh clone to hook interception: - -```bash -./scripts/smoke-test.sh -``` +## Readiness -The smoke test verifies / 这䞪测试䌚验证 -- `npm ci && npm run build` succeeds -- `lazybrain ready` reports the current readiness state -- `lazybrain hook plan` previews install changes without writing settings -- `lazybrain hook install` correctly modifies project `.claude/settings.json` -- `lazybrain scan && lazybrain compile` produces `~/.lazybrain/graph.json` -- Hook returns the tiny route reminder for a complex test prompt -- `lazybrain hook rollback` restores the latest LazyBrain backup +`lazybrain ready` separates product readiness from transient local hook/runtime state. Stale persisted runtime status is reported as stale without blocking product readiness. -See [`scripts/smoke-test.sh`](scripts/smoke-test.sh) for the full test implementation. +## Public Package -### Release and Review Gate +The npm package is limited to `dist`, `README.md`, `README_CN.md`, `CHANGELOG.md`, `LICENSE`, and package metadata. -Required before release PRs: +## Verification ```bash -npm ci -npm run build -npm test npm run lint npm run audit:public -npm pack --dry-run --json -``` - -The stable required GitHub check is `Test`. It runs Node 18/20/22, package dry-run, public privacy scan, version consistency checks, hook-focused tests, and Lab/server smoke. - -Public package contents are limited to `dist`, `README.md`, `README_CN.md`, `CHANGELOG.md`, `LICENSE`, and package metadata. npm publishing is handled by the GitHub Release workflow. - -Optional Codex review instructions are in [`docs/REVIEW.md`](docs/REVIEW.md). - -#### MCP and Manual Fallback - -Use MCP when the primary model should pull structured advice itself: - -```bash -lazybrain mcp status -lazybrain mcp --stdio -``` - -Use prompt output when MCP is not configured: - -```bash -lazybrain prompt "review this PR" --target claude -lazybrain prompt "debug this stuck hook" --target codex --copy -``` - -`lazybrain hook install` installs `UserPromptSubmit` only and automatically removes stale LazyBrain `Stop` registrations left by older versions. The default hook is a tiny reminder gate; it does not run the old startup dashboard, Secretary path, or full recommendation injection. - -### Config - -```bash -lazybrain config show # Show current redacted config -lazybrain config set # Set config value -``` - -## Data Directory - -``` -~/.lazybrain/ -├── config.json # Configuration -├── graph.json # Knowledge graph (local capability graph) -├── graph.embeddings.bin # Semantic vector cache -├── graph.embeddings.index.json -├── hook-install-map.json # Project/global hook install metadata -├── history.jsonl # Usage history -├── profile.json # Distilled user profile -├── last-match.json # Latest match result -└── wiki/ # Capability wiki indices and category pages -``` - -## Source Structure - -``` -src/ -├── scanner/ # Tool discovery & parsers (skill/agent/command) -├── compiler/ # LLM tag generation & category classification -├── graph/ # Graph CRUD & wiki generation -├── matcher/ # Matching engine -│ ├── alias-layer.ts # Layer 0: manual aliases -│ ├── tag-layer.ts # Layer 1: keyword + CJK bigram -│ ├── embedding-layer.ts # Layer 2: semantic/hybrid cache -│ └── matcher.ts # Orchestrator + history boost + corrections -├── lab/ # Non-install Lab UI, fixtures, agent inventory, evaluator -├── hook/ # Hook planning, install safety, rollback, readiness -├── server/ # Local HTTP API and Lab routes -├── secretary/ # Hook LLM second-pass judgment -├── history/ # Usage tracking & profile distillation -├── evolution/ # Tag evolution engine -├── config/ # Configuration management -└── utils/ # CJK bridge, progress, YAML +npm test +node dist/bin/lazybrain.js ready +node dist/bin/lazybrain.js ready --release +node dist/bin/lazybrain.js mcp status +node dist/bin/lazybrain.js embeddings status +node dist/bin/lazybrain.js route dogfood --target claude ``` - -## Benchmark - -| Mode | Top-1 | Top-3 | -|------|-------|-------| -| Route pipeline (tag + optional semantic) | varies by local graph | varies by local graph | -| Tag-only (offline) | baseline local match quality | baseline local match quality | - -Benchmark results depend on: - -- what capabilities exist on the current machine -- whether offline or LLM-assisted compile was used -- whether semantic cache is configured and current -- which evaluation set is being used - -## License - -MIT diff --git a/README_CN.md b/README_CN.md index 3b2b6af..82ab3c9 100644 --- a/README_CN.md +++ b/README_CN.md @@ -1,713 +1,81 @@ -
+# LazyBrain -# 🧠 LazyBrain +本地 AI coding agent 的胜力路由噚。 -**AI 猖皋助手的语义技胜路由噚 / 附属性智胜䜓** +## 圓前保留胜力 -[![CI](https://github.com/papperrollinggery/lazy-brain/actions/workflows/ci.yml/badge.svg)](https://github.com/papperrollinggery/lazy-brain/actions/workflows/ci.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -[![Node](https://img.shields.io/badge/node-%E2%89%A518-brightgreen.svg)](https://nodejs.org) - -> 䞀䞪莎圚䞻暡型旁蟹的附属性智胜䜓把零散工具库变成可理解、可路由、可衚蟟的胜力层。 -> 扫描胜力、猖译囟谱、路由倍杂任务并䞔䞍参䞎 `Stop` 生呜呚期竞争。 - -[English](README.md) | [䞭文文档](README_CN.md) - ---- - -
- -## 圓前版本 - -圓前版本**v1.4.5** - -发垃定䜍**䜎䟵入路由 beta 版**。这䞀版加固了 `RouteSpec`新增只读 MCP server、可倍制的目标 CLI prompt并把 Claude hook 改成 tiny gate。hook 只提醒䞻暡型圚倍杂任务前调甚 LazyBrain完敎掚荐保留圚 `lazybrain route`、`/api/route`、MCP、GUI 或星匏 prompt 蟓出里。 - -## 项目抂览 - -圚现代 AI 猖码环境里真正的问题通垞已经䞍是“胜力䞍借”而是 - -- skill 倪倚记䞍䜏名字 -- agent 倪倚䞍知道什么时候该叫谁 -- command 倪倚入口碎片化 -- 倚䞪插件重叠䜆没有统䞀路由层 -- 䞭英文混蟓时匹配效果䞍皳定 - -LazyBrain 的角色䞍是替代䞻暡型而是䜜䞺䞀䞪**附属性智胜䜓sidecar agent**莎圚䞻暡型旁蟹莟莣 - -- 扫描本地胜力面 -- 猖译胜力囟谱 -- 蟓出给䞻暡型䜿甚的 advisory route plan -- 通过 CLI、HTTP API、MCP、prompt 蟓出倍甚同䞀仜 route 契纊 -- 避免和记忆/通知插件争抢 `Stop` 生呜呚期 - -## 䞺什么芁有它 - -劂果没有路由层高级 AI 猖码环境通垞䌚退化成这样 - -- 明明装了埈倚胜力䜆几乎䞍甚 -- 䞭文需求匹配䞍到英文胜力名 -- 甚户被迫自己决定暡匏和工具 -- 花了埈倚时闎圚“扟入口”而䞍是“掚进任务” - -LazyBrain 的目标就是把零散工具库敎理成䞀䞪可路由、可解释、可成长的胜力层。 - -``` -䜠蟓入: "垮我审查这䞪 PR" -LazyBrain: → /review-pr (92%) | /critic (78%) | /santa-loop (71%) - → Route Plan䜿甚 code review + 回園检查 + 测试证据 -``` - -## 栞心特性 - -- **意囟䌘先**甚户描述目标䞍需芁记呜什名 -- **胜力无关**芆盖 skill、agent、command、mode、hook -- **䞭英双语**䞭文和英文查询郜䜜䞺䞀等蟓入倄理 -- **本地䌘先**scan、graph、wiki、tag-layer 郜䟝赖本地产物 -- **䜎䟵入生呜呚期**默讀 project-scoped `UserPromptSubmit` tiny gate䞍碰 `Stop`䞍默讀 `SessionStart` - -## 掚荐公匀䜿甚流皋 - -公匀甚户默讀走这条路埄 +栞心呜什 ```bash -lazybrain scan -lazybrain compile --offline +lazybrain route "审查这次改劚" --target codex --brief +lazybrain route "审查这次改劚" --target claude --json +lazybrain route dogfood --target claude lazybrain ready -lazybrain ui -lazybrain route "垮我审查这䞪 PR" -lazybrain prompt "垮我审查这䞪 PR" --target claude -lazybrain hook plan -lazybrain hook install -``` - -安党默讀倌 - -- Lab 䞍安装 hook䞍写 `.claude/settings.json` -- `hook plan` 只预挔 -- `hook install` 默讀 project scope并䞔先倇仜 -- 党局安装必须星匏䜿甚 `lazybrain hook install --global --yes` -- LazyBrain 䞍把 `Stop` 圓䜜产品生呜呚期 -- 默讀保留第䞉方 hook 和 HUD/statusline -- GUI v1 䞍盎接安装 hook只星瀺状态、预挔和 CLI 回退呜什 -- `lazybrain route` 只给建议䞍执行 skill也䞍写 Claude/Codex/Cursor 配眮 -- `lazybrain mcp` 只读䞍返回 agent 正文或私人 transcript -- 安装 hook 后只泚入短提醒`Consider calling lazybrain.route for skill routing, context reduction, and verification planning.` - -## 什么䌚被圓成技胜 / Agent / Capability - -LazyBrain 把本机 AI 工具䜓系统䞀看成 **capability**。䞀䞪 capability 可以是 - -- 垊 `SKILL.md` 的 skill 目圕 -- Claude / Agent Agency 的 agent markdown 文件 -- command markdown 文件 -- mode、hook 或插件扫描出来的胜力入口 - -对 skillLazyBrain 䌚读取 - -- frontmatter 里的 `name`、`description`、`trigger`、`triggers`、`origin` -- 可选 route schema 字段`useWhen`、`avoidWhen`、`inputs`、`workflow`、`verification`、`doneWhen`、`contextNeeded`、`guardrails` -- 没有 description 时甚正文第䞀䞪有效段萜䜜䞺 fallback -- 没有 name 时甚父目圕名䜜䞺 fallback - -对 agentLab 只展瀺公匀 metadata - -- `name` -- `description` -- `scope` -- `source` -- `model` -- `tools` - -Lab 䞍返回 agent 正文䞍读取 Claude 私人 transcript也䞍读取历史对话。scan/compile 䌚解析本地 markdown 来建囟䜆䞍䌚执行 skill 或 agent。 - -掚荐的 skill 写法 - -```markdown ---- -name: code-review -description: Review code for correctness, regressions, maintainability, and missing tests. -triggers: - - review code - - 审查代码 -useWhen: ["review code changes", "check regression risk"] -workflow: [{"title":"Inspect changed files"},{"title":"Prioritize behavioral findings"}] -verification: [{"title":"Run tests","command":"npm test"}] -doneWhen: ["Findings are grounded in file evidence or tests pass"] -contextNeeded: ["diff or branch", "expected behavior"] -guardrails: [{"title":"Lead with bugs and regressions","strength":"strict"}] ---- - -Use this skill when the user asks for a focused engineering review. -``` - -劂果某䞪 skill 没被扫到先确讀它圚已扫描路埄䞋有 `SKILL.md`并䞔有枅晰的 `name` 或 `description`。 - -## 已实现 / 规划䞭 - -| 胜力 | 圓前状态 | 诎明 | -|------|----------|------| -| 犻线路由 | 已实现 | 手工别名 + tag/CJK bridge无 API key 也可甚 | -| semantic / hybrid | 条件可甚 | 需芁 embedding 配眮和 `graph.embeddings.*` 猓存猺倱时降级并提瀺 | -| Route plan | 已实现 | `lazybrain route` 蟓出 v1.4.5 `RouteSpec`包含 `route_plan`、`needs_clarification`、`no_route_needed` | -| MCP | 已实现 | `lazybrain mcp --stdio` 暎露只读 route/search/card/combo 工具 | -| 手劚 prompt | 已实现 | `lazybrain prompt` 蟓出目标 CLI 风栌的可倍制建议 | -| Combo 暡板 | 已实现 | `lazybrain combos [category]` 只读展瀺高频猖排暡板 | -| hook 安装 | 已实现 | project 默讀 tiny gate、plan dry-run、倇仜、rollback、global 需 `--yes` | -| Lab | 已实现 | 内眮样䟋、本机 agent metadata、team gate、token 策略、hook readiness | -| Team 建议 | 已实现䞺 advisory | 给暡型/agent/prompt 建议最终决定权圚䞻暡型或甚户 | -| 自劚别名 | 规划䞭 | 圓前是建议/只读路埄䞍宣称完党成熟 | - -## Wiki 䞎囟谱产物 - -`lazybrain compile` 䌚把运行时产物写到 `~/.lazybrain/` 䞋。这些是䜠本机的囟谱和知识文件䞍是仓库源码的䞀郚分。 - -``` -~/.lazybrain/wiki/ -├── index.md -├── kinds.md -├── origins.md -├── development.md -├── operations.md -└── ... -``` - -芁点 - -- 分类页来自**固定分类䜓系 + 本地劚态園类** -- 最终分类数量取决于本机实际扫描到的胜力 -- README 里的数量劂果出现只是瀺䟋䞍是固定事实 -- wiki 芆盖的是 capability包括 skill、agent、command、mode、hook -- agent 和 command 圓前收圚分类页内郚也可以通过 `kinds.md`、`origins.md` 查看 - -## 怎么甚先测试再安装 - -**环境芁求**Node.js ≥ 18 - -### 完敎安装流皋倍制粘莎即可 - -䞋面每䞀步郜有验证呜什确讀䞊䞀步真的成功了再埀䞋走。 - -```bash -# ─── 第 0 步环境检查 ───────────────────────────────────────────── -node --version # 必须 ≥ 18.0.0 -which lazybrain 2>/dev/null && echo "已安装过" || echo "銖次安装" - -# ─── 第 1 步获取代码并构建 ────────────────────────────────────── -git clone https://github.com/papperrollinggery/lazy-brain.git -cd lazy-brain -npm install -npm run build -npm link # 泚册 lazybrain / lb 到党局 - -# 验证确讀呜什可甚 -lazybrain --version # 应蟓出 v1.4.5 或曎高 -which lazybrain # 应指向党局 node bin 目圕 - -# ─── 第 2 步扫描本机胜力 + 犻线猖译囟谱 ───────────────────────── -lazybrain scan # 扫描所有本地 skill/agent/command -lazybrain compile --offline # 䞍需芁 API key 也胜甚 - -# 验证确讀囟谱已生成 -lazybrain ready # 应星瀺 graph: ✅ 或类䌌确讀 -lazybrain list # 列出扫描到的所有胜力 -ls ~/.lazybrain/graph.json # 确讀囟谱文件存圚 - -# ─── 第 3 步Lab 预览可选䜆区烈掚荐 ────────────────────────── -lazybrain ui # 启劚 GUI打匀 http://127.0.0.1:18450/ -lazybrain route "垮我审查这䞪 PR" # 测试路由效果 - -# 验证GUI 页面胜打匀route 呜什返回掚荐结果 -# curl http://127.0.0.1:18450/api/status # 检查 API 状态 - -# ─── 第 4 步预挔 hook 安装 ────────────────────────────────────── -lazybrain hook plan # 预览䌚改什么䞍写任䜕文件 - -# 验证plan 蟓出应星瀺 Settings (project)、Hook、Statusline 䞉项计划 - -# ─── 第 5 步安装 hook ─────────────────────────────────────────── -lazybrain hook install # 默讀 project scope只写圓前项目的 .claude/settings.json - -# 劂果已经有第䞉方 HUD劂 claude-hud需芁组合暡匏 -# lazybrain hook install --statusline - -# 验证确讀安装成功 -lazybrain hook status # 应星瀺 UserPromptSubmit: ✅ 已安装 - -# ─── 第 6 步重启 Claude Code ──────────────────────────────────── -# 完党退出 Claude Code重新打匀圓前项目 -# 歀时状态栏应星瀺 🧠 埅机䞭dim 灰色 -# 蟓入䞀䞪倍杂任务应看到 🧠 思考䞭 或路由建议 - -# 甹 doctor 诊断敎䜓状态 -lazybrain doctor # 检查各项是吊正垞 - -从圓前版本匀始`lazybrain hook install` 只安装 - -- `UserPromptSubmit` - -它䌚自劚枅理旧版本残留的 LazyBrain `Stop` 泚册䞍再让 LazyBrain 参䞎 `running stop hooks`。 - -安装前䌚自劚倇仜 LazyBrain 觊蟟的配眮。需芁回滚时 - -```bash -lazybrain hook rollback -``` - -安装后䜠圚**圓前记圕的项目工䜜区里**䜿甚 Claude Code/CLI 时LazyBrain 只做 tiny gate。倍杂、暡糊、高风险任务䌚埗到䞀条短提醒让䞻暡型去调甚 RouteSpec完敎掚荐仍甚 CLI/API/MCP 拉取。 - -``` -䜠诎: "垮我审查代码" -LazyBrain hook: Consider calling lazybrain.route for skill routing, context reduction, and verification planning. -䞻暡型: 调甚 lazybrain.route 或䜿甚 MCP 工具 - -䜠手劚兜底: -lazybrain route "垮我审查代码" -lazybrain prompt "垮我审查代码" --target claude -``` - -正垞工䜜入口仍是 Claude / Codex / CursorLazyBrain 只做旁路增区。 - -## 日垞䜿甚方匏 - -公匀版掚荐这样甚 - -```bash -lazybrain --version # 确讀版本 -lazybrain scan # 刷新本地胜力 -lazybrain compile --offline # 无 API key 构建基础囟谱 -lazybrain match "垮我审查这䞪 PR" # 圚终端测试掚荐莚量 -lazybrain route "垮我审查这䞪 PR" # 生成 advisory RouteSpec -lazybrain prompt "垮我审查这䞪 PR" --target claude -lazybrain mcp status # 检查 MCP 入口是吊可甚 -lazybrain ready # 检查囟谱、hook、HUD、semantic 状态 -lazybrain ui # 启劚本地 GUI -lazybrain hook plan # 预览 hook 改劚 -lazybrain hook install # 安装 project scope hook -``` - -安装 hook 前先甚 GUI/Lab 盎观看效果 - -```bash -lazybrain ui -open http://127.0.0.1:18450/lab +lazybrain ready --release +lazybrain doctor --json +lazybrain embeddings status +lazybrain embeddings rebuild --yes +lazybrain mcp status ``` -劂果安装后效果䞍笊合预期盎接回滚 +刷新本地胜力囟谱 ```bash -lazybrain hook rollback -lazybrain hook status -``` - -## 它是怎么做到的 - -LazyBrain 有䞉䞪阶段党自劚运行 - -``` -䜠装的工具 ──scan──▶ 知识囟谱 ──compile──▶ 䜠诎话 ──hook──▶ 自劚掚荐 -(几十䞪Skill) (AI理解每䞪 (每䞪工具有了 (匹配䜠的 - 工具是干嘛的) 标筟和关系) 意囟) -``` - -**扫描 (scan)**扟到䜠电脑䞊所有的 Skill、Agent、呜什 -**猖译 (compile)**犻线生成基础囟谱配眮 LLM 后可生成曎䞰富的标筟和关系 -**挂钩 (hook)**装进 Claude Code每次䜠蟓入时自劚匹配 - -## 匹配匕擎圓前实现 - -䜠蟓入䞀句话后LazyBrain 䌚按顺序查扟最合适的工具 - -``` -䜠蟓入: "垮我审查这䞪 PR" - │ - â–Œ -┌──────────────────────────────────────────┐ -│ 第 1 层别名 │ -│ 䜠之前讟过快捷方匏吗 │ -│ 比劂 "review" 盎接跳到 /review-pr │ -│ ⚡ 0ms瞬闎呜䞭 │ -└──────────────┬───────────────────────────┘ - │ 没有 - â–Œ -┌──────────────────────────────────────────┐ -│ 第 2 层标筟匹配 │ -│ "审查" → 扩展䞺 ["review", "audit"] │ -│ 然后扟垊这些标筟的工具 │ -│ ⚡ <1ms䞍需芁眑络 │ -└──────────────┬───────────────────────────┘ - │ 拿䞍准 - â–Œ -┌──────────────────────────────────────────┐ -│ 第 3 层语义向量semantic/hybrid │ -│ 需芁 embedding 配眮和猓存可甚 │ -│ 猓存猺倱或过期时䌚降级并给 warning │ -└──────────────┬───────────────────────────┘ - │ 蟓出给 RouteSpec - â–Œ -┌──────────────────────────────────────────┐ -│ RouteSpectop-K 技胜 + token 策略 │ -│ route_plan / needs_clarification / │ -│ no_route_needed │ -└──────────────────────────────────────────┘ -``` - -**简单理解**先看手工别名 → 再看关键词 → 需芁时补语义 → RouteSpec 决定是吊路由、柄枅或盎接倄理。默讀 hook 䞍运行 Secretary。 - -**犻线也胜甚**别名和 tag-layer 䞍需芁眑络semantic/hybrid 需芁 embedding 配眮䞎猓存。 - -## 越甚越聪明四种进化胜力 - -LazyBrain 䞍只是垮䜠扟工具它䌚从䜠的䜿甚习惯䞭孊习 - -**1. 拒绝孊习** — 䜠拒绝的掚荐䞋次自劚降权 - -``` -䜠诎 "审查代码" → LazyBrain 掚荐 /wiki → 䜠没选 -䞋次再诎 "审查代码" → /wiki 排名自劚䞋降 -``` - -**2. 自劚别名规划䞭** — 重倍的选择变成快捷方匏 - -``` -䜠诎 "审查代码" → 选了 /code-review → 连续 3 次 -后续版本䌚把皳定重倍选择提升䞺快捷方匏 -``` - -**3. 标筟进化** — 从䜠的搜玢䞭孊新词 - -``` -䜠搜 "审查代码" → 系统发现 "审查" 䞍圚标筟里 -运行 lazybrain evolve → 自劚给盞关工具加䞊 "审查" 标筟 -以后搜 "审查" 就胜呜䞭了 -``` - -**4. 任务铟预刀** — 甚完 A掚荐 B - -``` -䜠刚甚了 /review-pr审查代码 -LazyBrain: "通垞审查完䌚重构芁䞍芁甚 /refactor-clean" -``` - -## 知识囟谱长什么样 - -猖译完成后LazyBrain 䌚建䞀匠知识囟谱。每䞪工具是䞀䞪节点工具之闎的关系是连线 - -``` - /review-pr ──depends_on──▶ /coding-standards - │ - ├──similar_to──▶ /code-reviewer - │ - ├──composes_with──▶ /refactor-clean - │ - └──similar_to──▶ /critic -``` - -**䞉种关系** -- **depends_on**䟝赖甚 A 之前需芁先有 B -- **similar_to**盞䌌A 和 B 功胜盞近区别是什么 -- **composes_with**组合A + B 䞀起甚效果曎奜 - -## 癟科 (Wiki) - -`lazybrain wiki` 䌚基于圓前囟谱生成䞀组本地 capability 文档䞍是“查询单䞪工具诎明”的圚线呜什。 - -圓前 wiki 结构包含䞉种入口 - -- `index.md`总玢匕 -- `kinds.md`按 `skill / agent / command / mode / hook` 聚合 -- `origins.md`按 `local / ECC / OMC / plugin / external` 等来源聚合 -- `*.md` 分类页按固定分类䜓系聚合并圚页内分成 `Skills / Agents / Commands / Other` - -生成后䌚写到 `~/.lazybrain/wiki/` - -``` -~/.lazybrain/wiki/ -├── index.md # 总目圕 -├── kinds.md # 按 capability 类型玢匕 -├── origins.md # 按来源玢匕 -├── code-quality.md # 代码莚量类 -├── development.md # 匀发类 -├── deployment.md # 郚眲类 -├── security.md # 安党类 -├── design.md # 讟计类 -└── ... +lazybrain scan +lazybrain compile --offline +lazybrain compile --with-relations ``` -## 完敎呜什列衚 - -| 呜什 | 诎明 | -|------|------| -| `lazybrain scan` | 扫描本地所有工具 | -| `lazybrain compile` | 猖译知识囟谱需芁 API key | -| `lazybrain compile --offline` | 犻线猖译䞍需芁 API key | -| `lazybrain match "䜠的话"` | 测试匹配效果 | -| `lazybrain route "䜠的任务"` | 蟓出只读 RouteSpec 猖排计划 | -| `lazybrain route "䜠的任务" --target codex` | 按目标 CLI 风栌枲染建议提瀺词 | -| `lazybrain route "䜠的任务" --json` | 蟓出皳定 JSON schema | -| `lazybrain route stats` | 查看只保存 hash 的路由统计 | -| `lazybrain prompt "䜠的任务" --target claude` | 蟓出可倍制的目标 CLI prompt | -| `lazybrain prompt "䜠的任务" --copy` | 星匏倍制 prompt 到剪莎板 | -| `lazybrain mcp --stdio` | 启劚只读 MCP server | -| `lazybrain mcp status` | 查看 MCP readiness 和工具列衚 | -| `lazybrain combos [category]` | 查看内眮组合暡板 | -| `lazybrain list` | 列出所有工具 | -| `lazybrain wiki` | 生成本地 wiki 目圕䞎玢匕 | -| `lazybrain stats` | 囟谱统计 | -| `lazybrain ready` | 检查是吊可安党安装或䜿甚 | -| `lazybrain ui` | 启劚本地 Web GUI | -| `lazybrain ui --no-open` | 启劚 GUI 䜆䞍自劚打匀浏览噚 | -| `lazybrain ui status` | 查看 GUI/server 状态 | -| `lazybrain ui stop` | 停止 GUI/server | -| `lazybrain server --daemon` | 盎接启劚本地 API server | -| `lazybrain api test` | 星匏测试 LLM/embedding API | -| `lazybrain embeddings status` | 查看 embedding cache 芆盖情况 | -| `lazybrain embeddings rebuild --yes` | 原子重建 embedding cache | -| `lazybrain suggest-aliases` | 查看建议的快捷方匏 | -| `lazybrain evolve` | 从䜿甚䞭孊习新标筟 | -| `lazybrain evolve --dry-run` | 预览孊习结果䞍实际修改 | -| `lazybrain evolve --rollback` | 撀销䞊次孊习 | -| `lazybrain hook plan` | 预挔 Hook 安装䞍写文件 | -| `lazybrain hook install` | 安装 Hook默讀 project scope | -| `lazybrain hook install --global --yes` | 星匏确讀后党局安装 | -| `lazybrain hook rollback` | 回滚最近䞀次 LazyBrain hook 安装 | -| `lazybrain hook uninstall` | 卞蜜 | -| `lazybrain hook status` | 检查 LazyBrain hook 状态 | -| `lazybrain hook status --json` | 蟓出机噚可读 hook runtime 状态 | -| `lazybrain hook ps` | 查看圓前掻跃 hook | -| `lazybrain hook clean` | 枅理倱效 runtime 记圕 | -| `lazybrain doctor` | 诊断 LazyBrain 运行状态 | -| `lazybrain doctor --fix` | 修倍 LazyBrain 自身状态挂移 | -| `lazybrain doctor --all` | 同时检查 project/global䞍执行修倍 | -| `lazybrain config show` | 查看脱敏配眮 | -| `lazybrain config set <键> <倌>` | 修改配眮 | - -## 本地 Web GUI +本地 HTTP workbench ```bash -lazybrain ui +lazybrain server lazybrain ui --no-open -lazybrain ui --port 18451 -lazybrain ui status -lazybrain ui stop ``` -GUI 入口 +皳定本地 API -- `GET /` 和 `GET /ui`本地状态銖页 -- `GET /lab`非安装匏掚荐 Lab -- `GET /api/status`readiness、囟谱、路由、hook、API、embedding、agent、server 状态 -- `POST /api/test`甚户点击后才星匏测试倖郚 API -- `POST /api/embeddings/rebuild`必须垊 `{ "confirm": "rebuild" }` +- `GET /api/status` +- `GET /api/routes` +- `GET /api/diagnostics` +- `POST /api/route` +- `POST /api/compile` +- `GET /api/compile/status` +- `GET /api/embeddings/status` +- `POST /api/embeddings/rebuild` +- `GET /api/config` +- `POST /api/config` +- `POST /api/test` -GUI v1 是状态型界面䞍读取 Claude transcript䞍返回 agent 正文䞍安装 hook䞍写 `.claude/settings.json`。 +## Route 蟓出 -## Lab非安装匏可视化测试 +`lazybrain route` 蟓出 RouteSpec `1.5.0`mode、intent、呜䞭胜力、route plan、guardrails、verification、done conditions、目标 agent advisory prompt以及确定性的掚荐选择。它只做建议䞍执行任务。 -```bash -lazybrain server --daemon -open http://127.0.0.1:18450/lab -``` - -Lab 甚内眮样䟋检查匹配莚量、team gate、token 策略、hook 安党状态和 Claude/Agent Agency 子智胜䜓映射䞍䌚安装 hook也䞍䌚写 `.claude/settings.json`。 - -Lab API - -- `GET /lab`本地无䟝赖页面 -- `GET /lab/fixtures`内眮评䌰样䟋 -- `GET /lab/agents`只返回本机 agent metadata名称、描述、scope、source、model、tools -- `POST /lab/evaluate`返回 match、team 建议、runtime adapters、token 策略、hook readiness 和 warnings - -agent inventory 䞍返回 agent 正文也䞍读取 Claude 私人 transcript。 +## MCP -## Hook 安党暡型 - -- `lazybrain hook install` 默讀是 **project scope** -- `lazybrain hook plan` 只预挔䞍写 `.claude/settings.json` 或 `~/.lazybrain/*` -- `lazybrain hook install` 䌚先创建 LazyBrain 倇仜再写入配眮 -- `lazybrain hook rollback` 只恢倍 LazyBrain 自劚倇仜过的文件 -- `lazybrain hook install --global` 必须加 `--yes` -- LazyBrain 只䌚圚记圕的项目根目圕䞋工䜜 -- 其他 cwd 的调甚䌚盎接 no-op 跳过 -- 默讀 hook 只做 tiny gate䞍运行 Secretary、wiki card、完敎 match 蟓出或 agent/team 展匀 -- 高莟蜜、并发䞊限、breaker、猺 graph、非 `UserPromptSubmit` 事件郜䌚 fail closed䞍阻塞甚户蟓入 -- `Stop` 仍然䞍属于产品生呜呚期 -- 默讀䞍芆盖第䞉方 HUD劂需同时星瀺䜿甚 `lazybrain hook install --statusline` -- `doctor --fix` 只修 LazyBrain 自身状态 - - 规范化 hook 泚册 - - 枅理 stale runtime 记圕 - - 枅陀 breaker 状态 - - 圚已有 metadata 前提䞋修倍 install metadata -- `doctor --fix` 䞍䌚自劚修改第䞉方插件也䞍䌚改系统服务 -- `doctor --all --fix` 被犁甚避免䞀次性误改倚䞪 scope - -## 卞蜜䞎回滚 +`lazybrain mcp --stdio` 暎露只读工具甚于路由规划、胜力搜玢、技胜卡片和组合暡板。 ```bash -lazybrain hook uninstall -lazybrain hook rollback -lazybrain hook rollback --to +lazybrain mcp status ``` -rollback 只恢倍 LazyBrain 自劚倇仜过的文件䞍删陀第䞉方 hook 文件。 - -## 默讀䞍䌚做什么 - -- 䞍默讀安装党局 hook -- 䞍参䞎 `Stop` -- 䞍删陀第䞉方 hook -- 䞍芆盖第䞉方 HUD -- 䞍圚 `hook plan` 䞭写任䜕配眮文件 -- 䞍圚 semantic cache 猺倱时假装 semantic 已启甚 -- 䞍从 hook 泚入完敎 skill body - -## 垞见问题䞎故障倄理 +## Ready -| 现象 | 先检查 | 倄理方匏 | -|------|--------|----------| -| `lazybrain ready` 提瀺 graph 猺倱 | `~/.lazybrain/graph.json` 䞍存圚 | 运行 `lazybrain scan && lazybrain compile --offline` | -| GUI 或 Lab 页面打䞍匀 | server 没启劚或端口䞍对 | 运行 `lazybrain ui`或 `lazybrain ui --port 18451` | -| Lab 没有 agent | 没扟到可读 agent metadata | 圚 `.claude/agents/` 或 `~/.claude/agents/` 攟 agent再刷新 Lab | -| `hook plan` 因 LazyBrain 残留圚 `Stop` 星瀺 `needs_attention` | 老版本 Hook 泚册残留 | 先看 plan`lazybrain hook install` 䌚枅理 LazyBrain 自己的 `Stop` 残留 | -| `hook install --global` 倱莥 | 党局安装需芁星匏确讀 | 只有确讀圱响所有 Claude 项目时才甚 `lazybrain hook install --global --yes` | -| hook 已安装䜆没有掚荐 | v1.4.5 hook 是 tiny gate䞍是完敎掚荐噚 | 运行 `lazybrain hook status --json`完敎计划甚 `lazybrain route "<同䞀句话>"` | -| 䞻暡型没䞻劚甚 LazyBrain | MCP 未配眮或任务被刀定䞺简单任务 | 甹 `lazybrain prompt "<同䞀句话>" --target claude` 手劚兜底或配眮 `lazybrain mcp --stdio` | -| 长时闎无蟓出后 hook 像是卡䜏 | breaker 或 stale runtime record 可胜存圚 | 运行 `lazybrain hook ps`、`lazybrain hook clean`、`lazybrain ready` | -| 已有第䞉方 HUD/statusline | LazyBrain 默讀跳过 | 需芁组合时甚 `lazybrain hook install --statusline`确讀替换时才甚 `--replace-statusline` | -| `lazybrain api test` 返回 401 | API key 无效或 base/model 䞍接受圓前 key | 重新讟眮对应 `...ApiKey` 后再运行 `lazybrain api test` | -| semantic/hybrid 没效果 | embedding 配眮猺倱、cache 过期或绎床䞍䞀臎 | 先运行 `lazybrain embeddings status`确讀配眮正确后运行 `lazybrain embeddings rebuild --yes` | -| 某䞪 skill 没出现圚结果里 | 路埄或 metadata 䞍完敎 | 确讀有 `SKILL.md`包含 `name` 或 `description`然后运行 `lazybrain scan` | +`lazybrain ready` 区分产品可甚状态和本机 hook/runtime 䞎时状态。过期 runtime status 䌚标记䞺 stale䞍阻塞产品可甚状态。 -安党恢倍呜什 +## 公匀包范囎 -```bash -lazybrain ready -lazybrain hook status -lazybrain hook status --json -lazybrain hook ps -lazybrain hook clean -lazybrain hook rollback -lazybrain doctor -lazybrain api test -lazybrain embeddings status -lazybrain route stats -lazybrain mcp status -``` - -`doctor --fix` 只修圓前 scope 例 LazyBrain 自己的状态。`doctor --all --fix` 被犁甚避免误改党局。 - -## 发垃䞎审栞闚犁 +npm 包只包含 `dist`、`README.md`、`README_CN.md`、`CHANGELOG.md`、`LICENSE` 和 package metadata。 -发垃 PR 前必须本地跑 +## 验证 ```bash -npm ci -npm run build -npm test npm run lint npm run audit:public -npm pack --dry-run --json -``` - -GitHub 必需检查只䟝赖皳定聚合 check`Test`。它芆盖 Node 18/20/22、package dry-run、公匀隐私扫描、版本䞀臎性、hook 重点测试和 Lab/server smoke。 - -公匀 npm 包只包含 `dist`、`README.md`、`README_CN.md`、`CHANGELOG.md`、`LICENSE` 和 package metadata。npm 发垃只通过 GitHub Release workflow。 - -可选 Codex 审查流皋见 [`docs/REVIEW.md`](docs/REVIEW.md)。 - -## MCP 和手劚兜底 - -䞻暡型支持 MCP 时甚只读 MCP 让暡型自己拉 RouteSpec - -```bash -lazybrain mcp status -lazybrain mcp --stdio -``` - -还没配眮 MCP 时甚 prompt 蟓出兜底 - -```bash -lazybrain prompt "垮我审查这䞪 PR" --target claude -lazybrain prompt "排查 hook 卡䜏" --target codex --copy -``` - -`lazybrain hook install` 只安装 `UserPromptSubmit`并自劚枅理旧版本残留的 LazyBrain `Stop` 泚册。默讀 hook 是 tiny reminder gate䞍运行旧的启劚回顟、Secretary 路埄或完敎掚荐泚入。 - -劂果䜠芁确讀圓前环境里 LazyBrain 是吊已经完党退出 `Stop`盎接运行 - -```bash -lazybrain hook status -``` - -䜠䌚看到类䌌 - -```text -UserPromptSubmit: ✅ 已安装 -Stop: ✅ 无 LazyBrain 泚册 -SessionStart: ℹ 无 LazyBrain 泚册 -``` - -## 配眮 - -第䞀次䜿甚可以先犻线猖译需芁 LLM 猖译或 semantic/hybrid 时再配眮 API key - -```bash -# 必需猖译甚 LLM -lazybrain config set compileApiBase https://api.siliconflow.cn/v1 -lazybrain config set compileApiKey <䜠的key> -lazybrain config set compileModel Qwen/Qwen3-235B-A22B-Instruct-2507 - -# 可选语义搜玢。需芁 embedding 配眮和 graph.embeddings.* 猓存可甚。 -lazybrain config set embeddingApiKey <䜠的key> -lazybrain config set embeddingApiBase https://api.siliconflow.cn/v1 -lazybrain config set embeddingModel BAAI/bge-m3 -lazybrain config set engine hybrid -lazybrain api test # 星匏测试倖郚 API -lazybrain embeddings status # 只读查看 cache 芆盖 -lazybrain embeddings rebuild --yes # 写入 ~/.lazybrain/graph.embeddings.* - -# 可选AI 秘乊 -lazybrain config set secretaryApiKey <䜠的key> -lazybrain config set secretaryModel Qwen/Qwen2.5-7B-Instruct - -# 界面暡匏 -lazybrain config set mode auto # 静默掚荐暡匏 -# lazybrain config set mode ask # 匹窗让䜠选 -``` - -掚荐甚 [SiliconFlow](https://siliconflow.cn)泚册送免莹额床bge-m3 embedding 免莹甚。 - -配眮文件䜍眮`~/.lazybrain/config.json` - -`lazybrain config show` 䌚对 API key 做脱敏展瀺。 - -## 数据郜圚哪 - -``` -~/.lazybrain/ -├── config.json # 䜠的配眮 -├── graph.json # 知识囟谱䜠本机圓前扫描出来的胜力囟谱 -├── graph.embeddings.bin # 语义向量猓存 -├── history.jsonl # 䜿甚记圕进化功胜的数据源 -├── profile.json # 䜠的䜿甚画像 -├── last-match.json # 最近䞀次匹配结果 -└── wiki/ # capability 文档index/kinds/origins + 分类页 +npm test +node dist/bin/lazybrain.js ready +node dist/bin/lazybrain.js ready --release +node dist/bin/lazybrain.js mcp status +node dist/bin/lazybrain.js embeddings status +node dist/bin/lazybrain.js route dogfood --target claude ``` - -## 性胜基准 - -| 暡匏 | Top-1 | Top-3 | -|------|-------|-------| -| 完敎流氎线联眑 | 取决于本地囟谱䞎评测集 | 取决于本地囟谱䞎评测集 | -| 仅标筟断眑 | 反映本地基础匹配胜力 | 反映本地基础匹配胜力 | - -基准结果䌚受到这些因玠圱响 - -- 䜠圓前机噚䞊实际扫描到了哪些胜力 -- 䜠甚的是犻线 compile 还是 LLM compile -- semantic cache 是吊配眮完敎䞔未过期 -- 䜿甚的评测集是什么 - -## 讞可证 - -MIT diff --git a/bin/hook.ts b/bin/hook.ts index 619b229..d5706bb 100644 --- a/bin/hook.ts +++ b/bin/hook.ts @@ -25,7 +25,7 @@ import { loadProfile, isProfileStale, distillAndSave } from '../src/history/prof import { writeRecommendation } from '../src/history/tool-usage-tracker.js'; import { generateProposals } from '../src/utils/token-estimate.js'; import { detectDuplicates, buildDuplicateIndex, findCapabilityByNameOrId, compareCapabilities } from '../src/graph/duplicate-detector.js'; -import { isServerRunning, getServerPort } from '../src/server/server.js'; +import { getServerRuntimeState } from '../src/server/server.js'; import type { DuplicatePair } from '../src/graph/duplicate-detector.js'; import type { WikiCard, SecretaryResponse, ProposalOption } from '../src/types.js'; import type { TeamComposition } from '../src/matcher/team-recommender.js'; @@ -42,13 +42,15 @@ import type { HookRunRecord } from '../src/hook/types.js'; import { classifyRouteNeed } from '../src/orchestrator/route-gate.js'; import { recordRouteEvent } from '../src/orchestrator/route-events.js'; import { tagMatch } from '../src/matcher/tag-layer.js'; +import { findCombo, formatComboEntryCommand, type ComboTemplate } from '../src/combos/registry.js'; import type { Capability } from '../src/types.js'; // ─── Server HTTP Client (optional fast path) ───────────────────────────────── async function tryMatchViaServer(prompt: string): Promise { - if (!isServerRunning()) return null; - const port = getServerPort(); + const serverState = getServerRuntimeState(); + if (!serverState.running) return null; + const port = serverState.port; try { const res = await fetch(`http://127.0.0.1:${port}/match`, { method: 'POST', @@ -412,14 +414,7 @@ function formatMatchInjection(matches: Array<{ name: string; score: number; reas if (routeMode === 'needs_clarification') { lines.push(`\n🀔 䜠的需求有点暡糊芁䞍芁甚 /${top.name} 试试`); } else if (top.score >= 0.75) { - const roasts = [ - '我垮䜠扟奜了别自己硬写啊', - '攟着现成的工具䞍甚手写䞍环吗', - '这䞪匹配床埈高信我䞀次', - '䜠每次郜䞍选我埈隟办啊', - ]; - const roast = roasts[Math.floor(Math.random() * roasts.length)]; - lines.push(`\n→ 建议甚 /${top.name}${roast}`); + lines.push(`\n→ 建议甚 /${top.name}匹配床高。`); } else { lines.push(`\n→ 建议调甚 /${top.name}或者看看䞊面哪䞪合适`); } @@ -428,13 +423,7 @@ function formatMatchInjection(matches: Array<{ name: string; score: number; reas if (routeMode === 'needs_clarification') { lines.push(`\n🀔 Your request is a bit vague — try /${top.name}?`); } else if (top.score >= 0.75) { - const roasts = [ - 'I already checked — save yourself the typing', - 'Trust me on this one, the match is solid', - 'Why do I even bother if you never pick these', - ]; - const roast = roasts[Math.floor(Math.random() * roasts.length)]; - lines.push(`\n→ Try /${top.name} — ${roast}`); + lines.push(`\n→ Try /${top.name}; match confidence is high.`); } else { lines.push(`\n→ Consider /${top.name}, or pick from above`); } @@ -442,12 +431,45 @@ function formatMatchInjection(matches: Array<{ name: string; score: number; reas return lines.join('\n'); } +function formatComboInjection(combo: ComboTemplate, lang: 'zh' | 'en', routeMode: string): string { + const lines: string[] = []; + const entryCommand = formatComboEntryCommand(combo, 'claude'); + if (lang === 'zh') { + lines.push('🧭 LazyBrain 路由建议'); + lines.push(` Combo: ${combo.title} (${combo.id})`); + lines.push(` 入口: ${entryCommand}`); + lines.push(` 暡匏: ${combo.executionMode}`); + lines.push(` 暡型策略: ${combo.modelStrategy}`); + lines.push(` Skill 铟: ${combo.skillNames.slice(0, 3).map(name => `/${name}`).join(' + ')}`); + const checks = combo.verification.slice(0, 2).map(item => item.command ?? item.title); + if (checks.length > 0) lines.push(` 验收: ${checks.join(' | ')}`); + lines.push(routeMode === 'needs_clarification' + ? '\n→ 先补霐目标页面/验收口埄再执行这条 route。' + : '\n→ 䌘先参考这条 combo route䞊䞋文䞍匹配时回退到普通匹配。'); + } else { + lines.push('🧭 LazyBrain route suggestion:'); + lines.push(` Combo: ${combo.title} (${combo.id})`); + lines.push(` Entry: ${entryCommand}`); + lines.push(` Mode: ${combo.executionMode}`); + lines.push(` Model strategy: ${combo.modelStrategy}`); + lines.push(` Skill chain: ${combo.skillNames.slice(0, 3).map(name => `/${name}`).join(' + ')}`); + const checks = combo.verification.slice(0, 2).map(item => item.command ?? item.title); + if (checks.length > 0) lines.push(` Verify: ${checks.join(' | ')}`); + lines.push(routeMode === 'needs_clarification' + ? '\n→ Clarify the target surface and acceptance check before executing this route.' + : '\n→ Prefer this combo route when it fits; otherwise use the matched skills.'); + } + return lines.join('\n'); +} + function runTinyGate(prompt: string): void { const decision = classifyRouteNeed(prompt); + const combo = decision.shouldCallLazyBrain ? findCombo(prompt) : undefined; recordRouteEvent({ query: prompt, source: 'hook-gate', mode: decision.mode, + combo: combo?.id, warnings: decision.mode === 'needs_clarification' ? ['needs_clarification'] : [], }); @@ -456,6 +478,13 @@ function runTinyGate(prompt: string): void { return; } + if (combo) { + const lang = detectLang(prompt); + writeLastMatch(combo.id, 1, 0, 'matched'); + output({ continue: true, additionalSystemPrompt: formatComboInjection(combo, lang, decision.mode) }); + return; + } + // Try a fast tag-layer match so we can show real results try { if (existsSync(GRAPH_PATH)) { diff --git a/bin/lazybrain.ts b/bin/lazybrain.ts index 78e674e..024a248 100644 --- a/bin/lazybrain.ts +++ b/bin/lazybrain.ts @@ -39,9 +39,11 @@ import { match } from '../src/matcher/matcher.js'; import { recommendTeam } from '../src/matcher/team-recommender.js'; import { scan } from '../src/scanner/scanner.js'; import { compile, makeCapabilityId } from '../src/compiler/compiler.js'; +import { formatCompileErrorReport, summarizeCompileErrors } from '../src/compiler/compile-errors.js'; import { createLLMProvider } from '../src/compiler/llm-provider.js'; import { classifyCategory } from '../src/compiler/category-classifier.js'; import { loadConfig, saveConfig, updateConfig } from '../src/config/config.js'; +import { validateConfigUpdate } from '../src/config/schema.js'; import { generateWiki } from '../src/graph/wiki-generator.js'; import { createProgressBar } from '../src/utils/progress.js'; import { loadRecentHistory } from '../src/history/history.js'; @@ -50,7 +52,7 @@ import { evolveCapabilities } from '../src/evolution/evolve.js'; import { generateReport, computeWeeklyStats, formatWeeklyReport } from '../src/history/accuracy-report.js'; import { detectDuplicates, findCapabilityByNameOrId, compareCapabilities } from '../src/graph/duplicate-detector.js'; import { buildGraphView, formatGraphMermaid } from '../src/graph/graph-view.js'; -import { createServer, isServerRunning, getServerPort, getServerPid, DEFAULT_PORT } from '../src/server/server.js'; +import { createServer, getServerRuntimeState, DEFAULT_PORT } from '../src/server/server.js'; import { execFileSync, spawn } from 'node:child_process'; import type { Capability, RawCapability, RouteTarget, UserConfig } from '../src/types.js'; import { buildSessionSummary, formatSessionSummary } from '../src/stats/session-summary.js'; @@ -73,10 +75,12 @@ import { runApiTests, type ApiTestTarget } from '../src/health/api-test.js'; import { getEmbeddingCacheStatus } from '../src/embeddings/cache.js'; import { rebuildEmbeddingCache } from '../src/embeddings/rebuild.js'; import { buildStatusReport } from '../src/server/status.js'; -import { buildRouteSpec, formatRouteSpec, isRouteTarget } from '../src/orchestrator/route.js'; +import { buildRouteSpec, formatRouteSpec, formatRouteSpecBrief, isRouteTarget } from '../src/orchestrator/route.js'; import { readRouteStats, recordRouteSpec } from '../src/orchestrator/route-events.js'; +import { DOGFOOD_ROUTE_CASES } from '../src/orchestrator/route-dogfood-cases.js'; import { formatComboList, listCombos } from '../src/combos/registry.js'; import { getMcpToolNames, runMcpStdioServer } from '../src/mcp/server.js'; +import { detectCapabilityConflicts, type CapabilityConflictDiagnostic } from '../src/diagnostics/conflicts.js'; const args = process.argv.slice(2); const cmd = args[0]; @@ -190,6 +194,31 @@ function readSettingsFile(path: string): Record { return JSON.parse(readFileSync(path, 'utf-8')) as Record; } +function mergeHookMaps(...hookMaps: Array | undefined>): Record { + const merged: Record = {}; + for (const hookMap of hookMaps) { + if (!hookMap) continue; + for (const [eventName, eventHooks] of Object.entries(hookMap)) { + if (Array.isArray(eventHooks)) { + const existing = merged[eventName]; + merged[eventName] = Array.isArray(existing) + ? [...existing, ...eventHooks] + : [...eventHooks]; + } else if (eventHooks !== undefined) { + merged[eventName] = eventHooks; + } + } + } + return merged; +} + +function settingsWithMergedHooks(settings: Record, hooks: Record): Record { + return { + ...settings, + hooks: mergeHookMaps(settings.hooks as Record | undefined, hooks), + }; +} + function getStatusLineCommand(statusLine: unknown): string { if (typeof statusLine === 'string') return statusLine; if (statusLine && typeof statusLine === 'object' && typeof (statusLine as { command?: unknown }).command === 'string') { @@ -319,7 +348,7 @@ async function main() { function cmdScan() { const config = loadConfig(); - writeFileSync(STATUS_PATH, JSON.stringify({ state: 'scanning', updatedAt: Date.now() })); + writeRuntimeStatus({ state: 'scanning' }); console.log('Scanning capability sources...'); // --platform : scan specific platform only @@ -377,7 +406,14 @@ function cmdScan() { } console.log(`\n Saved to ${scanCachePath}`); console.log(` Run 'lazybrain compile' to build the knowledge graph.`); - writeFileSync(STATUS_PATH, JSON.stringify({ state: 'idle', updatedAt: Date.now() })); + writeRuntimeStatus({ + state: 'idle', + lastScanAt: Date.now(), + scannedFiles: result.scannedFiles, + scannedPaths: result.scannedPaths, + capabilitiesFound: result.capabilities.length, + newCapabilities: newOnes.slice(0, 20).map(capability => capability.name), + }); } // ─── Interactive Platform Selection ────────────────────────────────────── @@ -438,6 +474,11 @@ async function interactiveSelect( // ─── Compile ────────────────────────────────────────────────────────────── async function cmdCompile() { + if (args[1] === 'errors') { + cmdCompileErrors(); + return; + } + // ─── Concurrency lock ───────────────────────────────────────────────────── const lockPath = join(LAZYBRAIN_DIR, 'compile.lock'); if (existsSync(lockPath)) { @@ -557,7 +598,7 @@ async function cmdCompile() { console.log(` By kind: ${JSON.stringify(s.byKind)}`); console.log(`\n Saved to ${GRAPH_PATH}`); console.log(` Run 'lazybrain match ""' to test matching.`); - writeFileSync(STATUS_PATH, JSON.stringify({ state: 'idle', updatedAt: Date.now() })); + writeRuntimeStatus({ state: 'idle', lastCompileAt: Date.now(), lastCompileErrorCount: 0, lastCompileErrors: [] }); } else { // LLM mode console.log(` Mode: LLM (${config.compileModel})`); @@ -574,7 +615,7 @@ async function cmdCompile() { const sigintHandler = () => { liveGraph.save(GRAPH_PATH); - writeFileSync(STATUS_PATH, JSON.stringify({ state: 'idle', updatedAt: Date.now(), interrupted: true })); + writeRuntimeStatus({ state: 'idle', interrupted: true }); console.log(`\n\nInterrupted. Saved ${liveGraph.getAllNodes().length} nodes to ${GRAPH_PATH}`); console.log('Run `lazybrain compile` (without --force) to resume.'); process.exit(0); @@ -583,38 +624,39 @@ async function cmdCompile() { const phase1Bar = createProgressBar({ label: 'Phase 1/2 Tags & Categories' }); phase1Bar.start(rawCapabilities.length); - writeFileSync(STATUS_PATH, JSON.stringify({ state: 'compiling', progress: `0/${rawCapabilities.length}`, updatedAt: Date.now() })); + writeRuntimeStatus({ state: 'compiling', progress: `0/${rawCapabilities.length}` }); const phase2Bar = createProgressBar({ label: 'Phase 2/2 Relation Inference' }); + let phase2Started = false; const result = await compile(rawCapabilities, { llm, modelName: config.compileModel, existingGraph: liveGraph, - forceRelations: args.includes('--force'), + forceRelations: args.includes('--force') || args.includes('--force-relations'), skipRelations: !args.includes('--with-relations'), config: { compileSystemPrompt: config.compileSystemPrompt, compileTagPrompt: config.compileTagPrompt, compileRelationPrompt: config.compileRelationPrompt }, checkpointPath: GRAPH_PATH, onProgress: (current, total, name) => { phase1Bar.update(current, name); - writeFileSync(STATUS_PATH, JSON.stringify({ state: 'compiling', progress: `${current}/${total}`, updatedAt: Date.now() })); + writeRuntimeStatus({ state: 'compiling', progress: `${current}/${total}` }); }, onRelationProgress: (current, total) => { - if (current === total) { + if (!phase2Started) { + phase2Bar.start(total); + phase2Started = true; + } + if (current >= total) { phase2Bar.complete(); } else { - if (current === 0) { - phase2Bar.start(total); - } phase2Bar.update(current); } }, }).catch((err) => { - writeFileSync(STATUS_PATH, JSON.stringify({ + writeRuntimeStatus({ state: 'idle', - updatedAt: Date.now(), lastError: err instanceof Error ? err.message : String(err), - })); + }); throw err; }); @@ -624,13 +666,60 @@ async function cmdCompile() { const elapsed = (phase1Bar.getElapsedSeconds() + (phase2Bar.getElapsedSeconds?.() ?? 0)).toFixed(0); const errors = result.errors.length; console.log(` ${errors === 0 ? '✓' : '⚠'} Compiled ${result.compiled} capabilities (${errors} errors, ${result.skipped} skipped)`); + if (errors > 0) { + console.log(' First errors:'); + for (const error of result.errors.slice(0, 5)) { + console.log(` - ${error.slice(0, 240)}`); + } + if (errors > 5) console.log(` ... ${errors - 5} more`); + } console.log(` Tokens: ${(result.totalTokens.input / 1000).toFixed(1)}K input / ${(result.totalTokens.output / 1000).toFixed(1)}K output`); console.log(` Time: ${elapsed}s`); const s = result.graph.stats(); console.log(` Nodes: ${s.nodes}, Links: ${s.links}`); console.log(`\n Saved to ${GRAPH_PATH}`); - writeFileSync(STATUS_PATH, JSON.stringify({ state: 'idle', updatedAt: Date.now() })); + writeRuntimeStatus({ + state: 'idle', + lastCompileAt: Date.now(), + lastCompileErrorCount: errors, + lastCompileErrors: result.errors.slice(0, 20), + }); + } +} + +function parseLimit(defaultValue = 20): number { + const limitIdx = args.indexOf('--limit'); + if (limitIdx === -1) return defaultValue; + const parsed = parseInt(args[limitIdx + 1], 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue; +} + +function cmdCompileErrors() { + const asJson = args.includes('--json'); + const limit = parseLimit(); + + if (!existsSync(GRAPH_PATH)) { + if (asJson) { + console.log(JSON.stringify({ total: 0, byCode: {}, errors: [], graphExists: false }, null, 2)); + return; + } + console.error('No graph found. Run `lazybrain scan && lazybrain compile` first.'); + process.exit(1); } + + const errors = Graph.load(GRAPH_PATH).getCompileErrors(); + const summary = summarizeCompileErrors(errors); + if (asJson) { + console.log(JSON.stringify({ + ...summary, + errors: errors.slice(0, limit), + truncated: errors.length > limit, + graphExists: true, + }, null, 2)); + return; + } + + console.log(formatCompileErrorReport(errors, limit)); } /** @@ -931,14 +1020,29 @@ async function cmdMatch(implicitQuery?: string) { // ─── Route Plan ─────────────────────────────────────────────────────────── -function parseRouteArgs(): { query: string; target: RouteTarget; asJson: boolean } { +function parseRouteTarget(defaultTarget: RouteTarget = 'generic'): RouteTarget { + let target = defaultTarget; + for (let i = 1; i < args.length; i++) { + if (args[i] !== '--target') continue; + const value = args[i + 1]; + if (!value || !isRouteTarget(value)) { + console.error('Usage: --target generic|claude|codex|cursor'); + process.exit(1); + } + target = value; + } + return target; +} + +function parseRouteArgs(): { query: string; target: RouteTarget; asJson: boolean; brief: boolean } { let target: RouteTarget = 'generic'; const asJson = args.includes('--json'); + const brief = args.includes('--brief') || args.includes('-b'); const queryParts: string[] = []; for (let i = 1; i < args.length; i++) { const arg = args[i]; - if (arg === '--json') continue; + if (arg === '--json' || arg === '--brief' || arg === '-b') continue; if (arg === '--target') { const value = args[i + 1]; if (!value || !isRouteTarget(value)) { @@ -952,7 +1056,7 @@ function parseRouteArgs(): { query: string; target: RouteTarget; asJson: boolean queryParts.push(arg); } - return { query: queryParts.join(' ').trim(), target, asJson }; + return { query: queryParts.join(' ').trim(), target, asJson, brief }; } function parsePromptArgs(): { query: string; target: RouteTarget; asJson: boolean; copy: boolean } { @@ -985,10 +1089,14 @@ async function cmdRoute() { console.log(JSON.stringify(readRouteStats(), null, 2)); return; } + if (args[1] === 'dogfood') { + await cmdRouteDogfood(); + return; + } - const { query, target, asJson } = parseRouteArgs(); + const { query, target, asJson, brief } = parseRouteArgs(); if (!query) { - console.error('Usage: lazybrain route "" [--target generic|claude|codex|cursor] [--json] | lazybrain route stats'); + console.error('Usage: lazybrain route "" [--target generic|claude|codex|cursor] [--json|--brief] | lazybrain route dogfood | lazybrain route stats'); process.exit(1); } @@ -1001,7 +1109,13 @@ async function cmdRoute() { const config = loadConfig(); const history = loadRecentHistory(50); const profile = loadProfile() ?? undefined; - const spec = await buildRouteSpec(query, { graph, config, history, profile, target }); + const spec = await buildRouteSpec(query, { + graph, + config, + history, + profile, + target, + }); recordRouteSpec(spec, 'cli'); if (asJson) { @@ -1009,7 +1123,66 @@ async function cmdRoute() { return; } - console.log(formatRouteSpec(spec)); + console.log(brief ? formatRouteSpecBrief(spec) : formatRouteSpec(spec)); +} + +async function cmdRouteDogfood() { + const target = parseRouteTarget('claude'); + const asJson = args.includes('--json'); + const verbose = args.includes('--verbose') || args.includes('-v'); + if (!existsSync(GRAPH_PATH)) { + console.error('No graph found. Run `lazybrain scan && lazybrain compile` first.'); + process.exit(1); + } + + const graph = Graph.load(GRAPH_PATH); + const config = loadConfig(); + const history = loadRecentHistory(50); + const profile = loadProfile() ?? undefined; + const rows = []; + for (const testCase of DOGFOOD_ROUTE_CASES) { + const spec = await buildRouteSpec(testCase.query, { + graph, + config, + history, + profile, + target, + }); + rows.push({ + label: `${testCase.category}:${testCase.query}`, + query: testCase.query, + expectedCombo: testCase.combo, + combo: spec.combo ?? null, + intent: spec.intent, + recommended: spec.choices.recommended.id, + pass: spec.combo === testCase.combo, + }); + } + + if (asJson) { + console.log(JSON.stringify({ target, passed: rows.every(row => row.pass), rows }, null, 2)); + return; + } + + const passed = rows.filter(row => row.pass).length; + if (!verbose) { + const status = passed === rows.length ? 'PASS' : 'FAIL'; + const failed = rows.filter(row => !row.pass).map(row => `${row.label}:${row.combo ?? '-'}!=${row.expectedCombo}`); + console.log(`LazyBrain route dogfood (${target}): ${status} ${passed}/${rows.length}`); + console.log(`Routes: ${rows.map(row => row.combo ?? '-').join(', ')}`); + if (failed.length > 0) console.log(`Failures: ${failed.join(', ')}`); + if (passed !== rows.length) process.exit(1); + return; + } + + console.log(`LazyBrain route dogfood (${target})`); + for (const row of rows) { + const mark = row.pass ? 'PASS' : 'FAIL'; + console.log(`${mark} ${row.label}: ${row.combo ?? '-'} | ${row.intent} | ${row.recommended}`); + if (!row.pass) console.log(` expected: ${row.expectedCombo}`); + } + console.log(`Result: ${passed}/${rows.length} passed`); + if (passed !== rows.length) process.exit(1); } async function cmdPrompt() { @@ -1027,7 +1200,13 @@ async function cmdPrompt() { const config = loadConfig(); const history = loadRecentHistory(50); const profile = loadProfile() ?? undefined; - const spec = await buildRouteSpec(query, { graph, config, history, profile, target }); + const spec = await buildRouteSpec(query, { + graph, + config, + history, + profile, + target, + }); recordRouteSpec(spec, 'prompt'); const prompt = spec.adapters[target]?.prompt ?? spec.adapters.generic.prompt; @@ -1410,10 +1589,20 @@ function cmdConfig() { // Try to parse as JSON for booleans/numbers let parsed: unknown = value; try { parsed = JSON.parse(value); } catch { /* keep as string */ } - updateConfig(key, parsed); - const displayValue = isSensitiveConfigKey(key) && typeof parsed === 'string' && parsed + const validation = validateConfigUpdate({ [key]: parsed }); + if (!validation.ok) { + console.error(validation.error); + process.exit(1); + } + if (validation.ignoredKeys.includes(key)) { + console.log(`Config unchanged: ${key} blank value ignored`); + break; + } + const nextValue = validation.patch[key]; + updateConfig(key, nextValue); + const displayValue = isSensitiveConfigKey(key) && typeof nextValue === 'string' && nextValue ? '' - : parsed; + : nextValue; console.log(`Config set: ${key} = ${JSON.stringify(displayValue)}`); break; } @@ -1626,6 +1815,7 @@ function cmdHook() { const sub = args[1]; const commandScope: HookInstallScope = args.includes('--global') ? 'global' : 'project'; const settingsPath = getClaudeSettingsPath(commandScope); + const hooksPath = getClaudeHooksPath(commandScope); // Resolve the hook script path from this binary's location const binDir = dirname(fileURLToPath(import.meta.url)); @@ -1634,8 +1824,13 @@ function cmdHook() { const combinedStatuslineScript = resolve(binDir, 'statusline-combined.js'); const statuslineChainPath = getScopedStatuslineChainPath(commandScope); const combinedStatuslineCommand = `env LAZYBRAIN_STATUSLINE_CHAIN=${shellQuote(statuslineChainPath)} node ${shellQuote(combinedStatuslineScript)}`; - const shouldInstallStatusline = args.includes('--statusline') || args.includes('--install-statusline'); const shouldReplaceStatusline = args.includes('--replace-statusline'); + const statuslineExplicitlyRequested = args.includes('--statusline') || args.includes('--install-statusline') || shouldReplaceStatusline; + const shouldInstallStatusline = ( + !args.includes('--no-statusline') && + !args.includes('--no-install-statusline') && + (commandScope === 'project' || statuslineExplicitlyRequested) + ); const isLazyBrainStatuslineCommand = (command: unknown): command is string => { if (typeof command !== 'string') return false; const normalized = command.replace(/\\/g, '/'); @@ -1658,8 +1853,15 @@ function cmdHook() { console.error(`Failed to parse ${settingsPath}`); process.exit(1); } + try { + settings = settingsWithMergedHooks(settings, readHooksFile(hooksPath)); + } catch { + console.error(`Failed to parse ${hooksPath}`); + process.exit(1); + } try { globalSettings = readSettingsFile(getClaudeSettingsPath('global')); + globalSettings = settingsWithMergedHooks(globalSettings, readHooksFile(getClaudeHooksPath('global'))); } catch {} const plan = buildHookPlan({ @@ -1709,7 +1911,6 @@ function cmdHook() { const installScope: HookInstallScope = commandScope; const workspaceRoot = installScope === 'project' ? resolve(process.cwd()) : undefined; - const hooksPath = getClaudeHooksPath(installScope); let settings: Record = {}; if (existsSync(settingsPath)) { try { @@ -1719,10 +1920,12 @@ function cmdHook() { process.exit(1); } } + settings = removeLazyBrainHookRegistrations(settings); const backup = createHookBackup({ scope: installScope, settingsPath, + hooksPath, statuslineChainPath, installStateMapPath: HOOK_INSTALL_STATE_MAP_PATH, legacyInstallStatePath: HOOK_INSTALL_STATE_PATH, @@ -1754,11 +1957,13 @@ function cmdHook() { } catch {} const hasOtherStatusline = Boolean(upstreamStatuslineCommand && !isLazyBrainStatuslineCommand(upstreamStatuslineCommand)); - const alreadyCombined = Boolean(existingStatuslineCommand && existingStatuslineCommand.includes('statusline-combined.js')); + const upstreamIsLazyBrainStatusline = isLazyBrainStatuslineCommand(upstreamStatuslineCommand); + const alreadyCombined = Boolean(upstreamIsLazyBrainStatusline && upstreamStatuslineCommand.includes('statusline-combined.js')); const shouldComposeStatusline = shouldInstallStatusline && hasOtherStatusline && !shouldReplaceStatusline; const shouldUseLazyBrainOnlyStatusline = ( shouldReplaceStatusline || (isLazyBrainStatuslineCommand(existingStatuslineCommand) && !alreadyCombined) || + (shouldInstallStatusline && upstreamIsLazyBrainStatusline && !alreadyCombined) || (!upstreamStatuslineCommand && shouldInstallStatusline) ); @@ -1778,7 +1983,16 @@ function cmdHook() { command: combinedStatuslineCommand, }; statuslineMode = 'combined'; - } else if (alreadyCombined && chainedUpstreamCommand) { + } else if (shouldInstallStatusline && alreadyCombined) { + if (!existsSync(statuslineChainPath)) { + mkdirSync(dirname(statuslineChainPath), { recursive: true }); + writeFileSync(statuslineChainPath, JSON.stringify({ + upstreamCommand: chainedUpstreamCommand, + upstreamType: chainedUpstreamCommand ? 'command-object' : 'none', + hadOriginalStatusLine: false, + installedAt: new Date().toISOString(), + }, null, 2)); + } settings.statusLine = { type: 'command', command: combinedStatuslineCommand, @@ -1821,9 +2035,18 @@ function cmdHook() { console.log(` Statusline: ${statuslineScript}`); } else if (hasOtherStatusline) { console.log(' Statusline: skipped because another statusLine is already configured.'); - console.log(' Re-run `lazybrain hook install --statusline` to combine with it, or `--replace-statusline` to replace it.'); + if (installScope === 'global') { + console.log(' Re-run with `--global --yes --statusline` to opt into global HUD composition.'); + } else { + console.log(' Re-run `lazybrain hook install` to combine with it, or `--replace-statusline` to replace it.'); + } } else { - console.log(' Statusline: not installed. Use `lazybrain hook install --statusline` if you want LazyBrain statusline and no existing HUD is configured.'); + console.log(installScope === 'global' + ? ' Statusline: not installed for global scope unless --statusline is explicitly requested.' + : ' Statusline: not installed because --no-statusline was requested.'); + } + if (statuslineMode === 'none' || statuslineMode === 'skipped') { + console.log(' Visibility: limited. Run `lazybrain hook install` without --no-statusline, then restart Claude Code.'); } console.log(' Runtime guard: 非目标项目 cwd 将盎接跳过'); console.log(` Restart Claude Code to activate.`); @@ -1930,62 +2153,59 @@ function cmdHook() { break; } case 'status': { - if (!existsSync(settingsPath)) { - if (args.includes('--json')) { - const config = loadConfig(); - const runtime = getHookRuntimeSnapshot({ config }); - const stats = getHookRuntimeStats(runtime); - console.log(JSON.stringify({ - scope: commandScope, - settingsPath, - lazybrainUserPromptSubmit: false, - lazybrainStop: false, - lazybrainSessionStart: false, - runtime: { - activeRuns: runtime.activeRuns.length, - hungRuns: runtime.hungRuns.length, - staleRuns: runtime.staleRuns.length, - lastSkipReason: runtime.health.lastSkipReason, - lastDurationMs: runtime.health.lastDurationMs, - breakerUntil: runtime.health.breakerUntil, - avgDurationMs: stats.avgDurationMs, - p95DurationMs: stats.p95DurationMs, - breakerOpen: stats.breakerOpen, - }, - }, null, 2)); - return; - } - console.log('No settings file found.'); - return; - } let settings: Record = {}; try { - settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record; + settings = readSettingsFile(settingsPath); } catch { console.error(`Failed to parse ${settingsPath}`); process.exit(1); } + let hookFileHooks: Record = {}; + try { + hookFileHooks = readHooksFile(hooksPath); + } catch { + console.error(`Failed to parse ${hooksPath}`); + process.exit(1); + } + const lifecycleSettings = settingsWithMergedHooks(settings, hookFileHooks); const config = loadConfig(); const runtime = getHookRuntimeSnapshot({ config }); - const status = getHookLifecycleStatus(settings, { + const status = getHookLifecycleStatus(lifecycleSettings, { runtime, installState: readHookInstallStateForScope(commandScope, commandScope === 'project' ? process.cwd() : undefined), }); const stopAudit = loadLatestStopHookAudit(process.cwd()); const installState = status.installState; + const statuslineMode = installState?.statuslineMode ?? 'none'; + let inheritedStatuslineCommand = ''; + if (commandScope === 'project' && !getStatusLineCommand(settings.statusLine)) { + try { + inheritedStatuslineCommand = getStatusLineCommand(readSettingsFile(getClaudeSettingsPath('global')).statusLine); + } catch {} + } + const statuslineCommand = getStatusLineCommand(settings.statusLine) || inheritedStatuslineCommand; + const statuslineVisible = isLazyBrainStatuslineCommand(statuslineCommand); if (args.includes('--json')) { console.log(JSON.stringify({ scope: commandScope, settingsPath, + hooksPath, lazybrainUserPromptSubmit: status.lazybrainUserPromptSubmit, lazybrainStop: status.lazybrainStop, lazybrainSessionStart: status.lazybrainSessionStart, + lazybrainUserPromptSubmitCount: status.lazybrainUserPromptSubmitCount, + lazybrainStopCount: status.lazybrainStopCount, + lazybrainSessionStartCount: status.lazybrainSessionStartCount, + duplicateLazyBrainUserPromptSubmit: status.duplicateLazyBrainUserPromptSubmit, userPromptSubmitCommands: status.userPromptSubmitCommands, stopCommands: status.stopCommands, sessionStartCommands: status.sessionStartCommands, installState, + statuslineMode, + statuslineVisible, + statuslineCommand, runtime: { activeRuns: status.runtime.activeRuns.length, hungRuns: status.runtime.hungRuns.length, @@ -2004,9 +2224,14 @@ function cmdHook() { console.log('LazyBrain hook 状态'); console.log(` UserPromptSubmit: ${status.lazybrainUserPromptSubmit ? '✅ 已安装' : '❌ 未安装'}`); + if (status.duplicateLazyBrainUserPromptSubmit) { + console.log(` UserPromptSubmit 重倍: ⚠ ${status.lazybrainUserPromptSubmitCount} 条`); + } console.log(` Stop: ${status.lazybrainStop ? '⚠ 仍存圚 LazyBrain 残留' : '✅ 无 LazyBrain 泚册'}`); console.log(` SessionStart: ${status.lazybrainSessionStart ? 'ℹ 含 LazyBrain' : 'ℹ 无 LazyBrain 泚册'}`); + console.log(` Hooks file: ${hooksPath}`); console.log(` Scope: ${installState ? installState.scope : 'unknown'}`); + console.log(` Statusline: ${statuslineVisible ? `✅ ${statuslineMode}` : `⚠ ${statuslineMode}`}`); if (installState?.workspaceRoot) { console.log(` Workspace root: ${installState.workspaceRoot}`); } @@ -2014,6 +2239,11 @@ function cmdHook() { console.log(` Hung hooks: ${status.runtime.hungRuns.length}`); console.log(` Breaker: ${status.breakerOpen ? 'OPEN' : 'closed'}`); console.log(` Avg / P95: ${status.avgDurationMs}ms / ${status.p95DurationMs}ms`); + if (!statuslineVisible) { + console.log(' Visibility: ⚠ HUD/statusline 未接入甚户䌚感觉 LazyBrain 没圚工䜜。'); + console.log(' Fix: lazybrain hook install'); + console.log(' Then restart Claude Code / cmux workspace.'); + } console.log(''); console.log('圓前 Stop 铟'); if (status.stopCommands.length === 0) { @@ -2083,7 +2313,7 @@ function cmdHook() { break; } default: - console.error('Usage: lazybrain hook [plan|install|rollback|uninstall|restore-statusline|status|ps|clean] [--statusline|--replace-statusline|--global|--yes]'); + console.error('Usage: lazybrain hook [plan|install|rollback|uninstall|restore-statusline|status|ps|clean] [--no-statusline|--replace-statusline|--global|--yes]'); process.exit(1); } } @@ -2102,9 +2332,105 @@ function getBudgetCheckerState(): string { } } -function printDoctorForScope(doctorScope: HookInstallScope, shouldFix: boolean): void { +type DoctorHookConflict = { + group: string; + winner: string; + suppressed: string[]; + severity: 'info' | 'warn' | 'block'; + reason: string; + suggestedAction?: string; +}; + +type DoctorReport = { + scope: HookInstallScope; + mode: 'diagnose' | 'diagnose+fix'; + paths: { + settings: string; + hooks: string; + }; + installState: { + present: boolean; + scope: string; + workspaceRoot?: string; + }; + lifecycle: { + userPromptSubmitInstalled: boolean; + userPromptSubmitCount: number; + stopClean: boolean; + }; + runtime: { + activeHooks: number; + hungHooks: number; + staleHooksCleaned: number; + breakerOpen: boolean; + avgDurationMs: number; + p95DurationMs: number; + lastSkipReason?: string; + lastError?: string; + }; + budgetChecker: string; + repairs: string[]; + conflicts: { + hooks: DoctorHookConflict[]; + capabilities: CapabilityConflictDiagnostic[]; + }; +}; + +function hookConflictDiagnostics(lifecycle: ReturnType): DoctorHookConflict[] { + const conflicts: DoctorHookConflict[] = []; + if (lifecycle.lazybrainUserPromptSubmitCount > 1) { + conflicts.push({ + group: 'hook:user-prompt-submit', + winner: 'lazybrain:user-prompt-submit', + suppressed: Array.from({ length: lifecycle.lazybrainUserPromptSubmitCount - 1 }, (_, index) => `duplicate:${index + 1}`), + severity: 'warn', + reason: 'Multiple LazyBrain UserPromptSubmit registrations are present; only one should own the event.', + suggestedAction: 'Run lazybrain doctor --fix for this scope to normalize LazyBrain-owned hook entries.', + }); + } + if (lifecycle.lazybrainStop) { + conflicts.push({ + group: 'hook:stop', + winner: 'none', + suppressed: ['lazybrain:stop'], + severity: 'warn', + reason: 'LazyBrain should not own Stop; Stop registrations are legacy and should be removed by doctor --fix.', + suggestedAction: 'Run lazybrain doctor --fix for this scope; it removes LazyBrain-owned legacy Stop entries without editing third-party hooks.', + }); + } + return conflicts; +} + +function graphConflictDiagnostics(): CapabilityConflictDiagnostic[] { + if (!existsSync(GRAPH_PATH)) return []; + try { + return detectCapabilityConflicts(Graph.load(GRAPH_PATH).getAllNodes()); + } catch { + return []; + } +} + +function printDoctorConflictGuidance(report: Pick): void { + const entries = [ + ...report.conflicts.hooks.map(conflict => ({ type: 'hook', conflict })), + ...report.conflicts.capabilities.map(conflict => ({ type: 'capability', conflict })), + ]; + if (entries.length === 0) return; + + console.log(' Conflict guidance:'); + for (const { type, conflict } of entries) { + console.log(` - [${conflict.severity}] ${type}:${conflict.group}`); + console.log(` Reason: ${conflict.reason}`); + if (conflict.suggestedAction) { + console.log(` Action: ${conflict.suggestedAction}`); + } + } +} + +function printDoctorForScope(doctorScope: HookInstallScope, shouldFix: boolean, options: { json?: boolean; silent?: boolean } = {}): DoctorReport { const config = loadConfig(); const settingsPath = getClaudeSettingsPath(doctorScope); + const hooksPath = getClaudeHooksPath(doctorScope); const budgetCheckerState = getBudgetCheckerState(); let settings: Record = {}; @@ -2113,6 +2439,10 @@ function printDoctorForScope(doctorScope: HookInstallScope, shouldFix: boolean): settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record; } catch {} } + let hooks: Record = {}; + try { + hooks = readHooksFile(hooksPath); + } catch {} const binDir = dirname(fileURLToPath(import.meta.url)); const hookScript = resolve(binDir, 'hook.js'); const hookCommand = `node ${hookScript}`; @@ -2122,10 +2452,16 @@ function printDoctorForScope(doctorScope: HookInstallScope, shouldFix: boolean): const existingState = readHookInstallStateForScope(doctorScope, doctorScope === 'project' ? process.cwd() : undefined); if (existingState) { settings = removeLazyBrainHookRegistrations(settings); - settings = upsertLazyBrainUserPromptSubmit(settings, hookCommand); mkdirSync(dirname(settingsPath), { recursive: true }); writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + const hooksSettings = upsertLazyBrainUserPromptSubmit( + removeLazyBrainHookRegistrations({ hooks } as Record), + hookCommand, + ); + hooks = (hooksSettings.hooks ?? hooksSettings) as Record; + writeHooksFile(hooksPath, hooks); + const repairedScope: HookInstallScope = existingState.scope; const repairedRoot = repairedScope === 'project' ? resolve(existingState.workspaceRoot ?? process.cwd()) @@ -2137,8 +2473,8 @@ function printDoctorForScope(doctorScope: HookInstallScope, shouldFix: boolean): installedAt: existingState.installedAt, statuslineMode: existingState.statuslineMode, }); - repairs.push('normalized_hook_registration'); - } else if (hasLazyBrainHookRegistration(settings)) { + repairs.push('normalized_hooks_json_registration'); + } else if (hasLazyBrainHookRegistration(settingsWithMergedHooks(settings, hooks))) { repairs.push('metadata_missing_manual_reinstall_required'); } @@ -2157,17 +2493,59 @@ function printDoctorForScope(doctorScope: HookInstallScope, shouldFix: boolean): const installState = readHookInstallStateForScope(doctorScope, doctorScope === 'project' ? process.cwd() : undefined); const runtime = getHookRuntimeSnapshot({ config }); const runtimeStats = getHookRuntimeStats(runtime); - const lifecycle = getHookLifecycleStatus(settings, { runtime, installState }); + const lifecycle = getHookLifecycleStatus(settingsWithMergedHooks(settings, hooks), { runtime, installState }); + const report: DoctorReport = { + scope: doctorScope, + mode: shouldFix ? 'diagnose+fix' : 'diagnose', + paths: { + settings: settingsPath, + hooks: hooksPath, + }, + installState: { + present: Boolean(installState), + scope: installState?.scope ?? 'unknown', + ...(installState?.workspaceRoot ? { workspaceRoot: installState.workspaceRoot } : {}), + }, + lifecycle: { + userPromptSubmitInstalled: lifecycle.lazybrainUserPromptSubmit, + userPromptSubmitCount: lifecycle.lazybrainUserPromptSubmitCount, + stopClean: !lifecycle.lazybrainStop, + }, + runtime: { + activeHooks: runtime.activeRuns.length, + hungHooks: runtime.hungRuns.length, + staleHooksCleaned: runtime.staleRuns.length, + breakerOpen: runtimeStats.breakerOpen, + avgDurationMs: runtimeStats.avgDurationMs, + p95DurationMs: runtimeStats.p95DurationMs, + ...(runtime.health.lastSkipReason ? { lastSkipReason: runtime.health.lastSkipReason } : {}), + ...(runtime.health.lastError ? { lastError: runtime.health.lastError } : {}), + }, + budgetChecker: budgetCheckerState, + repairs, + conflicts: { + hooks: hookConflictDiagnostics(lifecycle), + capabilities: graphConflictDiagnostics(), + }, + }; + + if (options.json) { + if (!options.silent) console.log(JSON.stringify(report, null, 2)); + return report; + } + if (options.silent) return report; console.log(`LazyBrain doctor (${doctorScope})`); console.log(` Mode: ${shouldFix ? 'diagnose+fix' : 'diagnose'}`); console.log(` Settings: ${settingsPath}`); + console.log(` Hooks file: ${hooksPath}`); console.log(` Install state: ${installState ? 'present' : 'missing'}`); console.log(` Scope: ${installState?.scope ?? 'unknown'}`); if (installState?.workspaceRoot) { console.log(` Workspace root: ${installState.workspaceRoot}`); } console.log(` UserPromptSubmit installed: ${lifecycle.lazybrainUserPromptSubmit ? 'yes' : 'no'}`); + console.log(` UserPromptSubmit count: ${lifecycle.lazybrainUserPromptSubmitCount}`); console.log(` Stop clean: ${lifecycle.lazybrainStop ? 'no' : 'yes'}`); console.log(` Active hooks: ${runtime.activeRuns.length}`); console.log(` Hung hooks: ${runtime.hungRuns.length}`); @@ -2184,22 +2562,33 @@ function printDoctorForScope(doctorScope: HookInstallScope, shouldFix: boolean): console.log(' Note: budget checker 已启甚䜆 doctor --fix 䞍䌚自劚修改 LaunchAgent 状态。'); } } + printDoctorConflictGuidance(report); + return report; } function cmdDoctor() { const shouldFix = args.includes('--fix'); const allScopes = args.includes('--all'); + const asJson = args.includes('--json'); if (allScopes && shouldFix) { console.error('doctor --all --fix is disabled. Run doctor --fix for one scope at a time.'); process.exit(1); } if (allScopes) { + if (asJson) { + const scopes = [ + printDoctorForScope('project', false, { json: true, silent: true }), + printDoctorForScope('global', false, { json: true, silent: true }), + ]; + console.log(JSON.stringify({ scopes }, null, 2)); + return; + } printDoctorForScope('project', false); console.log(''); printDoctorForScope('global', false); return; } - printDoctorForScope(args.includes('--global') ? 'global' : 'project', shouldFix); + printDoctorForScope(args.includes('--global') ? 'global' : 'project', shouldFix, { json: asJson }); } function readJsonStatus(path: string): Record | null { @@ -2211,26 +2600,47 @@ function readJsonStatus(path: string): Record | null { } } +function writeRuntimeStatus(patch: Record): void { + const existing = readJsonStatus(STATUS_PATH) ?? {}; + writeFileSync(STATUS_PATH, JSON.stringify({ ...existing, ...patch, updatedAt: Date.now() })); +} + function cmdReady() { + const releaseMode = args.includes('--release'); const config = loadConfig(); const status = readJsonStatus(STATUS_PATH); const runtime = getHookRuntimeSnapshot({ config }); const initialBlockers: string[] = []; + let compileErrors: string[] = []; + if (existsSync(GRAPH_PATH)) { + try { + compileErrors = Graph.load(GRAPH_PATH).getCompileErrors(); + } catch { + initialBlockers.push(`Graph is invalid JSON: ${GRAPH_PATH}`); + } + } const scopes = (['project', 'global'] as const).map((scope) => { const settingsPath = getClaudeSettingsPath(scope); + const hooksPath = getClaudeHooksPath(scope); let settings: Record = {}; try { settings = readSettingsFile(settingsPath); } catch { initialBlockers.push(`${scope} settings is invalid JSON: ${settingsPath}`); } + try { + settings = settingsWithMergedHooks(settings, readHooksFile(hooksPath)); + } catch { + initialBlockers.push(`${scope} hooks file is invalid JSON: ${hooksPath}`); + } const installState = readHookInstallStateForScope(scope, scope === 'project' ? process.cwd() : undefined); - return { scope, settingsPath, settings, installState }; + return { scope, settingsPath, hooksPath, settings, installState }; }); const report = evaluateReady({ graphExists: existsSync(GRAPH_PATH), + compileErrors, status, runtime, scopes, @@ -2239,10 +2649,14 @@ function cmdReady() { embeddingsIndexExists: existsSync(EMBEDDINGS_INDEX_PATH), embeddingsBinExists: existsSync(EMBEDDINGS_BIN_PATH), loadAverage1m: loadavg()[0], + ignoreLoadAverage: releaseMode, initialBlockers, }); console.log(report.state); + if (releaseMode) { + console.log('Mode: release readiness (host load average ignored)'); + } if (report.blockers.length > 0) { console.log('BLOCKERS:'); for (const blocker of report.blockers) console.log(` - ${blocker}`); @@ -2274,7 +2688,8 @@ function cmdHome(asJson: boolean): void { const graphInfo = status.graph as { nodes: number }; const readiness = status.readiness as { state: string; blockers: string[]; warnings: string[] }; - const embedding = status.embedding as { state: string; covered: number; active: number }; + const embedding = status.embedding as { state: string; covered: number; active: number; coveragePercent?: number }; + const unlock = status.unlock as { recentNewCapabilities?: string[]; missingEmbeddings?: number } | undefined; const routing = status.routing as { engine: string; apiConfigured: { compile: boolean; secretary: boolean; embedding: boolean } }; const hook = status.hook as { scopes: Array<{ scope: string; installed: boolean; stopClean: boolean }>; breakerOpen: boolean; hungRuns: number }; const server = status.server as { running: boolean; url: string }; @@ -2287,7 +2702,10 @@ function cmdHome(asJson: boolean): void { console.log(`Graph ${statusLabel(graphInfo.nodes > 0)} ${graphInfo.nodes} capabilities`); console.log(`Hook ${statusLabel(hookOk, !projectHook?.installed)} ${projectHook?.installed ? 'project installed' : 'not installed'} | ${projectHook?.stopClean ? 'Stop clean' : 'Stop dirty'}`); console.log(`LLM/API ${statusLabel(apiOk, !apiOk)} compile ${routing.apiConfigured.compile ? 'configured' : 'missing'} | secretary ${routing.apiConfigured.secretary ? 'configured' : 'missing'}`); - console.log(`Embedding ${statusLabel(embedding.state === 'ok', embedding.state !== 'missing' && embedding.state !== 'invalid')} ${embedding.state.toUpperCase()} | ${embedding.covered}/${embedding.active} covered`); + console.log(`Embedding ${statusLabel(embedding.state === 'ok', embedding.state !== 'missing' && embedding.state !== 'invalid')} ${embedding.state.toUpperCase()} | ${embedding.covered}/${embedding.active} covered (${embedding.coveragePercent ?? Math.round((embedding.covered / Math.max(1, embedding.active)) * 100)}%)`); + if (unlock?.recentNewCapabilities?.length) { + console.log(`Unlock WARN ${unlock.missingEmbeddings ?? 0} missing embeddings | ${unlock.recentNewCapabilities.slice(0, 3).join(', ')}`); + } console.log(`Server ${server.running ? 'OK' : 'IDLE'} ${server.running ? server.url : 'lazybrain ui'}`); console.log(`Agents ${agents.total > 0 ? 'OK' : 'WARN'} ${agents.available}/${agents.total} available\n`); @@ -2332,7 +2750,7 @@ async function cmdApi(): Promise { for (const result of report.results) { const state = result.ok ? 'OK' : result.configured ? 'ERROR' : 'MISSING'; const detail = result.ok - ? `${result.model ?? ''}${result.dim ? ` dim=${result.dim}` : ''}` + ? `${result.model ?? ''}${result.dim ? ` dim=${result.dim}` : ''}${result.latencyMs !== undefined ? ` ${result.latencyMs}ms` : ''}` : result.error ?? 'unknown error'; console.log(` ${result.target.padEnd(9)} ${state.padEnd(7)} ${result.apiBase ?? '(no base)'} ${detail}`); } @@ -2351,6 +2769,10 @@ function cmdEmbeddingsStatus(asJson: boolean): void { console.log(` Covered: ${status.covered}/${status.active}`); console.log(` Coverage: ${Math.round(status.coverage * 100)}%`); console.log(` Dimension: ${status.dim ?? '(unknown)'}`); + if (status.provider || status.model) console.log(` Provider: ${status.provider ?? '(unknown)'} ${status.model ?? ''}`.trimEnd()); + if (status.missingIds.length > 0) { + console.log(` Missing: ${status.missingIds.slice(0, 5).join(', ')}${status.missingIds.length > 5 ? ` (+${status.missingIds.length - 5})` : ''}`); + } console.log(` Message: ${status.message}`); } @@ -2367,12 +2789,19 @@ async function cmdEmbeddings(): Promise { process.exit(1); } const graph = Graph.load(GRAPH_PATH); - const result = await rebuildEmbeddingCache(graph.getAllNodes(), loadConfig()); + const force = args.includes('--force'); + writeRuntimeStatus({ state: 'embedding', progress: force ? 'full' : 'incremental' }); + const result = await rebuildEmbeddingCache(graph.getAllNodes(), loadConfig(), { force }); + writeRuntimeStatus({ state: 'idle', lastEmbeddingAt: Date.now(), lastEmbeddingResult: result.ok ? 'ok' : 'failed' }); if (asJson) { console.log(JSON.stringify(result, null, 2)); } else { console.log(`Embedding rebuild: ${result.ok ? 'OK' : 'FAILED'}`); + console.log(` Mode: ${result.mode}`); console.log(` Indexed: ${result.indexed}`); + console.log(` Embedded: ${result.embedded}`); + console.log(` Reused: ${result.reused}`); + console.log(` Removed: ${result.removed}`); console.log(` Dimension: ${result.dim || '(unknown)'}`); console.log(` Status: ${result.status.state}`); if (result.error) console.log(` Error: ${result.error}`); @@ -2380,7 +2809,7 @@ async function cmdEmbeddings(): Promise { if (!result.ok) process.exit(1); return; } - console.error('Usage: lazybrain embeddings [status|rebuild --yes] [--json]'); + console.error('Usage: lazybrain embeddings [status|rebuild --yes [--force]] [--json]'); process.exit(1); } @@ -2390,7 +2819,11 @@ async function cmdServer() { const subCmd = args[1]; if (subCmd === 'stop') { - const pid = getServerPid(); + const { running, pid } = getServerRuntimeState(); + if (!running) { + console.log('Server is not running.'); + return; + } if (!pid) { console.log('Server is not running.'); return; @@ -2405,9 +2838,8 @@ async function cmdServer() { } if (subCmd === 'status') { - if (isServerRunning()) { - const port = getServerPort(); - const pid = getServerPid(); + const { running, port, pid } = getServerRuntimeState(); + if (running) { console.log(`Server is running on http://127.0.0.1:${port} (pid ${pid})`); } else { console.log('Server is not running.'); @@ -2451,8 +2883,9 @@ async function cmdServer() { async function cmdUi(): Promise { const sub = args[1]; if (sub === 'status') { - if (isServerRunning()) { - console.log(`UI is available at http://127.0.0.1:${getServerPort()}/ (pid ${getServerPid()})`); + const { running, port, pid } = getServerRuntimeState(); + if (running) { + console.log(`UI is available at http://127.0.0.1:${port}/ (pid ${pid})`); } else { console.log('UI server is not running.'); } @@ -2466,8 +2899,9 @@ async function cmdUi(): Promise { const portIdx = args.indexOf('--port'); const requestedPort = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : DEFAULT_PORT; - const port = isServerRunning() ? getServerPort() : requestedPort; - if (!isServerRunning()) { + const serverState = getServerRuntimeState(); + const port = serverState.running ? serverState.port : requestedPort; + if (!serverState.running) { const child = spawn(process.execPath, [process.argv[1], 'server', '--port', String(port)], { detached: true, stdio: 'ignore', @@ -2562,15 +2996,21 @@ Usage: lazybrain scan [--platform

] Scan capability sources lazybrain compile [--offline] Build knowledge graph (--offline: no LLM) lazybrain compile --with-relations Include Phase 2 relation inference (slow) + lazybrain compile --with-relations --force-relations + Re-run relation inference for existing graph nodes + lazybrain compile errors [--json] Show persisted compile errors from graph.json lazybrain compile --all Compile all platforms lazybrain compile --select Interactive platform selection lazybrain compile --platform

Compile specific platform only lazybrain compile --tier Compile specific tier (0/1/2) lazybrain match "" Match input to capabilities lazybrain route "" Build an advisory route plan + lazybrain route "" --brief Print a short dogfood-friendly route summary lazybrain route "" --json Output stable RouteSpec JSON lazybrain route "" --target generic|claude|codex|cursor Render target-specific advisory prompt + lazybrain route dogfood Run compact core route acceptance checks + lazybrain route dogfood --verbose Print every route acceptance case lazybrain route stats Show privacy-preserving routing counters lazybrain prompt "" --target claude|codex|cursor Print a copyable target-specific route prompt @@ -2599,17 +3039,22 @@ Usage: lazybrain server status Check server status lazybrain api test [--json] Test configured LLM/embedding APIs explicitly lazybrain embeddings status Show embedding cache coverage - lazybrain embeddings rebuild --yes Rebuild embedding cache atomically + lazybrain embeddings rebuild --yes [--force] Rebuild embedding cache atomically lazybrain ready Check graph, hook, HUD, and semantic readiness + lazybrain ready --release Check release readiness without transient host load lazybrain hook plan Preview hook install changes without writing files - lazybrain hook install Install project-scoped Claude Code hook + lazybrain hook install Install project hook + visible statusline/HUD + lazybrain hook install --no-statusline + Install hook only, without LazyBrain statusline lazybrain hook install --global --yes Install global hook after explicit confirmation + lazybrain hook install --global --yes --statusline + Also opt into global LazyBrain statusline composition lazybrain hook rollback Restore latest LazyBrain hook backup lazybrain hook status Show LazyBrain hook lifecycle status lazybrain hook ps Show active LazyBrain hook runs lazybrain hook clean Remove stale LazyBrain hook records - lazybrain doctor [--fix|--all] Show runtime diagnostics and optional self-repair + lazybrain doctor [--json|--fix|--all] Show runtime diagnostics and optional self-repair lazybrain summary Show manual session audit lazybrain --version Show version `); diff --git a/bin/statusline-combined.ts b/bin/statusline-combined.ts index d3cfd43..eacbda5 100644 --- a/bin/statusline-combined.ts +++ b/bin/statusline-combined.ts @@ -28,8 +28,17 @@ function readStdin(): string { } function readChainConfig(): ChainConfig { + const explicitChainPath = process.env.LAZYBRAIN_STATUSLINE_CHAIN; + if (explicitChainPath) { + try { + if (!existsSync(explicitChainPath)) return {}; + return JSON.parse(readFileSync(explicitChainPath, 'utf-8')) as ChainConfig; + } catch { + return {}; + } + } + const candidates = [ - process.env.LAZYBRAIN_STATUSLINE_CHAIN, join(resolve(process.cwd(), '.claude'), 'lazybrain-statusline-chain.json'), getStatuslineChainPath(), `${process.env.HOME ?? ''}/.lazybrain/statusline-chain.json`, @@ -45,14 +54,14 @@ function readChainConfig(): ChainConfig { return {}; } -function runCommand(command: string, stdin: string): string { +function runCommand(command: string, stdin: string, extraEnv: Record = {}): string { try { return execSync(command, { input: stdin, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000, - env: process.env, + env: { ...process.env, ...extraEnv }, }).trim(); } catch { return ''; @@ -71,7 +80,11 @@ function main(): void { ? runCommand(upstreamCommand, stdin) : ''; const upstream = simplifyUpstreamHud(upstreamRaw); - const lazybrain = runCommand(`node ${JSON.stringify(new URL('./statusline.js', import.meta.url).pathname)}`, stdin); + const lazybrain = runCommand( + `node ${JSON.stringify(new URL('./statusline.js', import.meta.url).pathname)}`, + stdin, + { LAZYBRAIN_STATUSLINE_LABEL_ONLY: '1' }, + ); if (upstream && lazybrain) { if (isLowSignalLazyBrainLabel(lazybrain)) { diff --git a/bin/statusline.ts b/bin/statusline.ts index 73be741..70609b0 100644 --- a/bin/statusline.ts +++ b/bin/statusline.ts @@ -7,17 +7,19 @@ * 1. compile/scan in progress → 猖译䞭 / 扫描䞭 * 2. hook running → 思考䞭 * 3. last-match available → /tool [score%] with timeAgo - * 4. no history / idle → 埅机䞭 + * 4. no history / idle → 埅机䞭 * * Visual convention: - * - Active states (hooked/matched/routing): bold + * - Active states (hooked/matched): bold * - Dormant state (埅机䞭): dimmed to signal idle */ import { readFileSync, existsSync, readdirSync } from 'node:fs'; +import { execSync } from 'node:child_process'; import { join } from 'node:path'; -import { LAZYBRAIN_DIR, STATUS_PATH, HOOK_ACTIVE_PATH, HOOK_RUNS_DIR, ROUTE_EVENTS_PATH } from '../src/constants.js'; +import { LAZYBRAIN_DIR, STATUS_PATH, HOOK_ACTIVE_PATH, HOOK_RUNS_DIR, getStatuslineChainPath } from '../src/constants.js'; import { readOmcMode } from '../src/utils/omc-state.js'; +import { simplifyUpstreamHud, isLowSignalLazyBrainLabel } from '../src/utils/hud-normalizer.js'; // ─── ANSI styling ─────────────────────────────────────────────────────────────── @@ -29,18 +31,52 @@ function active(label: string): string { return `${BOLD}${label}${RST}`; } function dormant(label: string): string { return `${DIM}${label}${RST}`; } const lastMatchPath = join(LAZYBRAIN_DIR, 'last-match.json'); -const RECENT_STATUS_WINDOW_MS = 5 * 60 * 1000; -type RouteEventMode = 'route_plan' | 'needs_clarification' | 'no_route_needed'; - -interface RouteEvent { - timestamp: string; - source?: string; - mode: RouteEventMode; +interface ChainConfig { + upstreamCommand?: string; } // ─── Helpers ────────────────────────────────────────────────────────────────── +function readStdin(): string { + try { + return readFileSync(0, 'utf-8'); + } catch { + return ''; + } +} + +function readChainConfig(): ChainConfig { + const explicitChainPath = process.env.LAZYBRAIN_STATUSLINE_CHAIN; + const candidates = explicitChainPath + ? [explicitChainPath] + : [getStatuslineChainPath(), `${process.env.HOME ?? ''}/.lazybrain/statusline-chain.json`]; + + for (const chainPath of candidates) { + try { + if (!chainPath || !existsSync(chainPath)) continue; + return JSON.parse(readFileSync(chainPath, 'utf-8')) as ChainConfig; + } catch {} + } + + return {}; +} + +function runCommand(command: string, stdin: string): string { + if (command.includes('statusline.js') || command.includes('statusline-combined.js')) return ''; + try { + return execSync(command, { + input: stdin, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'ignore'], + timeout: 5000, + env: process.env, + }).trim(); + } catch { + return ''; + } +} + /** Check if hook is currently running (PID file exists + process alive) */ function isHookRunning(): boolean { try { @@ -76,6 +112,7 @@ function getCompileStatus(): string | null { if (Date.now() - data.updatedAt > fiveMin) return null; if (data.state === 'compiling') return `猖译䞭 ${data.progress}`; if (data.state === 'scanning') return '扫描䞭'; + if (data.state === 'embedding') return `Embedding ${data.progress ?? ''}`.trim(); } catch {} return null; } @@ -111,35 +148,6 @@ function readLastMatch(): { tool: string | null; score: number; historyBoost: nu } } -function parseRouteEvent(line: string): RouteEvent | null { - try { - const event = JSON.parse(line) as RouteEvent; - if (!event.timestamp || !event.mode) return null; - return event; - } catch { - return null; - } -} - -function readRecentRouteEvent(): { mode: RouteEventMode; age: number } | null { - try { - if (!existsSync(ROUTE_EVENTS_PATH)) return null; - const lines = readFileSync(ROUTE_EVENTS_PATH, 'utf-8').trim().split('\n'); - for (let index = lines.length - 1; index >= 0; index -= 1) { - const line = lines[index]; - if (!line) continue; - const event = parseRouteEvent(line); - if (!event || event.source !== 'hook-gate') continue; - const timestamp = Date.parse(event.timestamp); - if (!Number.isFinite(timestamp)) continue; - const age = Date.now() - timestamp; - if (age > RECENT_STATUS_WINDOW_MS) return null; - return { mode: event.mode, age }; - } - } catch {} - return null; -} - const OMC_MODE_LABELS: Record = { ralph: 'Ralph', ultrawork: 'Ultrawork', @@ -184,25 +192,28 @@ function getLabel(): string { } } - const recentRouteEvent = readRecentRouteEvent(); - if (recentRouteEvent?.mode === 'route_plan') { - return active(`\u{1F9E0} ${timeAgo(recentRouteEvent.age)} 建议路由${omcSuffix}`); - } - if (recentRouteEvent?.mode === 'needs_clarification') { - return active(`\u{1F9E0} ${timeAgo(recentRouteEvent.age)} 需柄枅${omcSuffix}`); - } - - // (5) idle — dimmed to distinguish from active states + // (6) idle — dimmed to distinguish from active states return dormant(`\u{1F9E0} 埅机䞭${omcSuffix}`); } function render() { + const stdin = readStdin(); const label = getLabel(); if (process.argv.includes('--json')) { process.stdout.write(JSON.stringify({ label }) + '\n'); - } else { - process.stdout.write(label + '\n'); + return; } + + if (process.env.LAZYBRAIN_STATUSLINE_LABEL_ONLY !== '1') { + const upstreamCommand = readChainConfig().upstreamCommand?.trim(); + const upstream = upstreamCommand ? simplifyUpstreamHud(runCommand(upstreamCommand, stdin)) : ''; + if (upstream) { + process.stdout.write(isLowSignalLazyBrainLabel(label) ? `${upstream}\n` : `${upstream} ${label}\n`); + return; + } + } + + process.stdout.write(label + '\n'); } render(); diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index 73eb600..3e56981 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -1,254 +1,47 @@ -# Codex Handoff — LazyBrain +# CODEX_HANDOFF -## Project Purpose +Current branch: `codex/lazybrain-route-compile-split`. -LazyBrain is a semantic capability router for AI coding agents. It scans local -skills, agents, commands, and hooks, compiles them into a capability graph, and -matches user intent to the right capability at prompt time. +## Current Scope -The product must not be Claude-only. Its long-term direction is a cross-client -capability layer for: +This branch is being slimmed in place. Keep only verified core behavior: -- Claude Code -- Codex -- OpenCode -- OpenClaw -- Hermes -- Cursor / Kiro / other agent runtimes +- `lazybrain route` +- MCP status/tools +- compile and embedding status/rebuild +- `lazybrain ready` and `ready --release` +- statusline/runtime truth separation +- `/api/status`, `/api/diagnostics`, `/api/route`, route event readback -## Product Direction +Removed product surface: -The v1 product should stay CLI/hook-first, but must visibly communicate value: +- choice preference CLI +- choice preference storage and feedback APIs +- route adoption/regression APIs +- public jobs/repairs/doctor-fix APIs +- config schema/test APIs +- unfinished multi-page UI panels and Cytoscape graph UI +- long planning docs for unshipped adaptive routing/UI work -- What decision LazyBrain made -- Why it picked a capability -- What alternatives existed -- What decision it made and what work it avoided -- Which runtime/model layer is being used +## GitNexus Notes -Future UI direction is a desktop companion / virtual pet, but it should be the -visible companion shell around a reliable routing engine, not a separate product -that hides weak routing. +High-risk symbols already checked before edits: -## Current Priorities +- `buildRouteSpec`: HIGH +- `buildStatusReport`: CRITICAL +- `createRouter`: LOW -1. Make value visible in Claude Code and other terminals. -2. Keep matching bilingual: Chinese and English queries should both work. -3. Expand platform support beyond Claude Code. -4. Keep metrics honest: never label total usage as "savings". -5. Preserve the future desktop UI path through the local HTTP API. +Final gate: MCP `detect_changes(scope=all)`. If GitNexus reports a stale index, run `npx gitnexus analyze` first. -## Platform Compatibility Requirements +## Required Verification -Capability metadata must keep platform compatibility explicit. A capability may -be universal, platform-specific, or shared across platforms. - -Current platform IDs: - -- `claude-code` -- `codex` -- `opencode` -- `openclaw` -- `hermes` -- `cursor` -- `kiro` -- `workbuddy` -- `droid` -- `universal` - -When adding scanner support, avoid assuming every skill is Claude-compatible. -Prefer explicit compatibility inferred from file paths and frontmatter. - -## Bilingual Requirements - -Chinese and English are both first-class. Do not treat Chinese matching as a -translation afterthought. - -Required behavior: - -- Chinese query to English capability should work. -- English query to Chinese capability should work. -- Mixed CJK + Latin queries should work. -- Explanation text should follow user language when possible. - -Relevant files: - -- `src/utils/cjk-bridge.ts` -- `src/matcher/tag-layer.ts` -- `test/benchmark/golden-set.json` - -## Desktop Companion Direction - -The desktop virtual pet should eventually be a companion surface for the existing -engine: - -- Shows current mode, budget, and active routing decisions -- Explains why it picked a tool -- Surfaces summaries and warnings -- Lets the user approve escalation to expensive models - -Do not start with animations or a heavy UI framework. The sequence should be: - -1. CLI/hook visibility -2. Local HTTP API stability -3. Lightweight companion status surface -4. Full desktop virtual pet - -## Operating Guidance - -- Do not replace strong-manager reasoning with MiniMax-style execution models. -- Use strong models for high-level judgment only when the decision is worth it. -- Use cheaper models/runtimes for execution, tests, docs, and local iteration. -- Prefer code-backed improvements over strategy-only documents. -- Always verify with tests before claiming completion. - -## Recent Codex Changes - -- Repositioned session summary as a manual audit surface instead of a - Stop-hook-driven “savings” report. -- Converted the session dashboard from a table into a narrative value surface. -- Added initial Hermes platform support and scanner paths. -- Removed LazyBrain from the `Stop` lifecycle. Hook install now keeps - `UserPromptSubmit` only and treats `Stop` as legacy compatibility no-op. -- Session recap responsibility moved to `SessionStart`, sourced from local - recommendation/history data instead of transcript parsing. - -## Current Working State - -This workspace now includes several in-progress but validated changes aimed at -turning LazyBrain from a pure capability router into a companion sidecar agent. - -### Routing / Matching - -- Added bilingual query normalization and broader CJK-English bridging. -- Improved team recommendation for abstract Chinese prompts and broader agent - inventory. -- Plugin scanning now includes nested `agents/*.md` and `commands/*.md`, not - just `SKILL.md`. - -### Hook / HUD / Compatibility - -- Decision card output was moved into Claude hook context to reduce folded - blocks in the CLI. -- Team bridge context now auto-injects for team-shaped prompts. -- Governance schema, preflight, and policy skeletons were added. -- Control/meta prompts such as "䞍芁继续" or "只蟓出验收诎明" now bypass routing - so LazyBrain does not misfire with `/debug`-style recommendations. -- LazyBrain statusline no longer shows "无候选" for bypassed prompts. -- Combined HUD layer now suppresses low-signal LazyBrain labels like "已跳过" - when an upstream HUD is already present. -- Upstream verbose token lines are normalized into a shorter cumulative form at - the combination layer rather than by patching the upstream plugin. - -### Graph Surface - -- The repo previously had `graph.json` plus wiki markdown, but no direct graph - visualization/export surface. -- A minimal graph view export now exists: - - CLI: `lazybrain graph --limit 20` - - Mermaid: `lazybrain graph --mermaid --limit 20` - - HTTP: `GET /graph` and `GET /graph?format=mermaid&limit=20` -- Relationship quality is still noisy; this view is useful for inspection, not - yet a final user-facing truth surface. - -## Current Product Judgment - -The capability graph / wiki stack is still valuable, but it should be treated -as the memory and retrieval substrate, not as the main product brain. - -Recommended mental model: - -- LazyBrain is a companion / sidecar agent. -- Claude/Codex/OpenCode/etc. remain the primary executors. -- LazyBrain owns: - - memory - - routing - - governance - - expression -- It should not try to replace the main model's core reasoning loop. - -## Known Risks / Open Questions - -- Claude `Stop` hooks may still be crowded because of other plugins. LazyBrain - should no longer appear in that chain after reinstalling hooks, but users may - still observe slow `Stop` behavior from unrelated plugins. -- Hook install now defaults to project scope. LazyBrain should only activate - inside the recorded workspace root, and should fail closed if install - metadata is missing. -- HUD semantics are still not fully clean. Current token display should be - treated as cumulative consumption, not savings. -- Natural-language heavy-mode detection is still weaker than explicit mode - detection. Governance works best today on clear signals. -- Relation inference for the graph still produces noisy edges; it should be - denoised before becoming a polished product surface. - -## Routing Status After V1 Match Tuning - -The latest routing pass is fully green on the current golden benchmark and is -safe to treat as the new baseline. - -### Current benchmark status - -- Top-1: `55/55 = 100.0%` -- Top-3: `55/55 = 100.0%` -- Chinese Top-1: `33/33 = 100.0%` -- Chinese Top-3: `33/33 = 100.0%` -- Tag-only Top-3: `55/55 = 100.0%` - -### Fixed regressions that should stay protected - -- `讟计系统架构` - - should continue to rank `Backend Architect / architect / Software Architect` - above generic planning commands -- `重构代码让它曎简掁` - - should continue to surface `refactor-clean / code-simplifier` -- `提亀代码` - - should continue to surface `prp-commit / code-review / git-master` -- `数据库查询䌘化` - - should continue to surface `prompt-optimize / Database Optimizer` -- `代码库新人䞊手` - - should continue to surface onboarding-aligned capabilities instead of - generic docs/search commands - -### Guardrails for future routing changes - -- Keep `category` as a secondary signal only. Do not let category alone trigger - intent-cluster boosts. -- Prefer targeted query-side expansions over widening generic planning / - development / documentation boosts. -- Re-run: - - `npm run build` - - `npm test` - - `npm test -- test/benchmark/match-quality.test.ts` - before claiming routing improvements. - -## New Session Resume Advice - -In a fresh session, do not rely on prior chat memory. Read this file first, then -inspect: - -- `bin/hook.ts` -- `bin/statusline.ts` -- `bin/statusline-combined.ts` -- `src/governance/` -- `src/hook/runtime.ts` -- `src/hook/install-state.ts` - -## Claude / LazyBrain Safety Model - -- `lazybrain hook install` defaults to project scope -- runtime activation is guarded by workspace cwd -- `lazybrain doctor` is the first diagnostic entrypoint -- `lazybrain doctor --fix` only repairs LazyBrain-owned state: - - normalize hook registration - - clean stale runtime records - - clear breaker state - - preserve existing install metadata when available -- `doctor --fix` must not silently rebind an unknown installation to a new - project and must not modify third-party plugins or system services -- `src/graph/graph-view.ts` -- `src/utils/meta-prompt.ts` -- `src/utils/hud-normalizer.ts` - -Then continue from the current product framing: companion sidecar agent, not -just a skill router. +```bash +npm run lint +npm run audit:public +npm test +node dist/bin/lazybrain.js ready +node dist/bin/lazybrain.js ready --release +node dist/bin/lazybrain.js mcp status +node dist/bin/lazybrain.js embeddings status +node dist/bin/lazybrain.js route dogfood --target claude +``` diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index c3d932e..39c2a3a 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -7,8 +7,8 @@ # 2. npm ci && npm run build # 3. lazybrain scan && lazybrain compile --offline # 4. lazybrain ready && lazybrain hook plan -# 5. lazybrain hook install → modifies project .claude/settings.json -# 6. Send a test prompt via stdin to the hook → verify tiny route reminder +# 5. lazybrain hook install → writes project .claude/hooks/hooks.json +# 6. Send a test prompt via stdin to the hook → verify route context injection # 7. Cleanup (rollback hook + remove temp dir) # # Usage: ./scripts/smoke-test.sh @@ -19,6 +19,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" TEMP_DIR="" HOOK_INSTALLED=0 +HOOK_HEALTH_PATH="${HOME}/.lazybrain/hook-health.json" +HOOK_HEALTH_BACKUP="" +HOOK_HEALTH_HAD_FILE=0 +CONFIG_PATH="${HOME}/.lazybrain/config.json" +CONFIG_BACKUP="" +CONFIG_HAD_FILE=0 # Colors RED='\033[0;31m' @@ -40,6 +46,26 @@ cleanup() { log_info "Rolled back lazybrain hook" fi + if [[ -n "$HOOK_HEALTH_BACKUP" ]]; then + if [[ "$HOOK_HEALTH_HAD_FILE" -eq 1 && -f "$HOOK_HEALTH_BACKUP" ]]; then + mkdir -p "$(dirname "$HOOK_HEALTH_PATH")" + mv "$HOOK_HEALTH_BACKUP" "$HOOK_HEALTH_PATH" + log_info "Restored hook runtime health" + elif [[ "$HOOK_HEALTH_HAD_FILE" -eq 0 ]]; then + rm -f "$HOOK_HEALTH_PATH" "$HOOK_HEALTH_BACKUP" + fi + fi + + if [[ -n "$CONFIG_BACKUP" ]]; then + if [[ "$CONFIG_HAD_FILE" -eq 1 && -f "$CONFIG_BACKUP" ]]; then + mkdir -p "$(dirname "$CONFIG_PATH")" + mv "$CONFIG_BACKUP" "$CONFIG_PATH" + log_info "Restored LazyBrain config" + elif [[ "$CONFIG_HAD_FILE" -eq 0 ]]; then + rm -f "$CONFIG_PATH" "$CONFIG_BACKUP" + fi + fi + if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then rm -rf "$TEMP_DIR" log_info "Removed temp dir: $TEMP_DIR" @@ -158,9 +184,9 @@ fi log_pass "hook plan output" echo -# Step 10: Install hook into project settings +# Step 10: Install hook into project hooks file log_info "Step 10: Install LazyBrain hook" -SETTINGS_PATH="$TEMP_DIR/.claude/settings.json" +HOOKS_PATH="$TEMP_DIR/.claude/hooks/hooks.json" if ! "$TEMP_DIR/dist/bin/lazybrain.js" hook install; then log_error "lazybrain hook install failed" exit 1 @@ -169,18 +195,35 @@ HOOK_INSTALLED=1 log_pass "Hook installed" echo -# Step 11: Verify settings.json was modified -log_info "Step 11: Verify project settings.json contains LazyBrain hook" -if ! grep -q "lazybrain" "$SETTINGS_PATH"; then - log_error "project settings.json does not contain lazybrain hook" +# Step 11: Verify hooks.json was modified +log_info "Step 11: Verify project hooks.json contains LazyBrain hook" +if ! grep -q "lazybrain" "$HOOKS_PATH"; then + log_error "project hooks.json does not contain lazybrain hook" exit 1 fi -log_pass "project settings.json modified" +log_pass "project hooks.json modified" echo # Step 12: Send test prompt to hook via stdin and verify response log_info "Step 12: Test hook with UserPromptSubmit event" +# Keep this E2E assertion independent from the developer machine's prior hook +# runtime health. A recently slow hook run can legitimately skip injection. +HOOK_HEALTH_BACKUP="$TEMP_DIR/hook-health.before-smoke.json" +if [[ -f "$HOOK_HEALTH_PATH" ]]; then + HOOK_HEALTH_HAD_FILE=1 + cp "$HOOK_HEALTH_PATH" "$HOOK_HEALTH_BACKUP" +fi +rm -f "$HOOK_HEALTH_PATH" + +CONFIG_BACKUP="$TEMP_DIR/config.before-smoke.json" +if [[ -f "$CONFIG_PATH" ]]; then + CONFIG_HAD_FILE=1 + cp "$CONFIG_PATH" "$CONFIG_BACKUP" +fi +mkdir -p "$(dirname "$CONFIG_PATH")" +node -e "const fs=require('fs'); const p=process.argv[1]; let c={}; try { c=JSON.parse(fs.readFileSync(p,'utf8')); } catch {} c.hookSafety={...(c.hookSafety||{}), loadAvgBreaker: 9999, avgDurationBreakerMs: 999999}; fs.writeFileSync(p, JSON.stringify(c,null,2)+'\n');" "$CONFIG_PATH" + # Build the stdin payload matching Claude Code hook protocol TEST_PROMPT="垮我审查这段代码" HOOK_INPUT=$(cat <"', + executionMode: 'guided', + modelStrategy: 'Use a frontend-capable model and keep verification in the same turn.', keywords: ['new page', 'frontend', 'ui', 'screen', '页面', '前端', '新页面', '界面'], + negativeKeywords: ['凜数', '方法', '暡块', 'class', 'api', '接口', '后端', 'backend', 'server'], skillNames: ['frontend-design', 'frontend-patterns', 'e2e-testing'], workflow: [ step('understand-user-flow', 'Identify the primary user workflow'), @@ -56,7 +64,11 @@ export const COMBOS: ComboTemplate[] = [ title: 'Existing frontend redesign', category: 'frontend', description: 'Improve an existing interface while preserving product behavior.', - keywords: ['redesign', 'existing', 'refactor ui', '改版', '重讟计', '䌘化界面', '现有页面'], + entryCommand: 'lazybrain route ""', + executionMode: 'guided', + modelStrategy: 'Use a frontend-capable model, inspect the current route, then verify before/after behavior.', + keywords: ['redesign', 'existing', 'refactor ui', '改版', '重讟计', '重新讟计', '重构', '眑页', '页面', '界面', '䌘化界面', '䌘化眑页', '现有页面', '现有眑页'], + negativeKeywords: ['凜数', '方法', '暡块', 'class', 'api', '接口', '后端', 'backend', 'server', '数据库', '代码'], skillNames: ['frontend-design', 'design-review', 'e2e-testing'], workflow: [ step('inspect-existing-ui', 'Inspect the existing UI and design conventions'), @@ -73,6 +85,9 @@ export const COMBOS: ComboTemplate[] = [ title: 'CEO dashboard', category: 'dashboard', description: 'Turn operational data into a decision-oriented dashboard.', + entryCommand: 'lazybrain route ""', + executionMode: 'guided', + modelStrategy: 'Use a product-logic-first model pass before visual implementation.', keywords: ['ceo dashboard', 'dashboard', 'metrics', 'ops', '后台', '看板', 'CEO', '运营', '指标'], skillNames: ['dashboard-builder', 'product-capability', 'frontend-design'], workflow: [ @@ -91,6 +106,9 @@ export const COMBOS: ComboTemplate[] = [ title: 'Public install docs', category: 'docs', description: 'Write public-facing installation and recovery documentation.', + entryCommand: 'lazybrain route ""', + executionMode: 'advisory', + modelStrategy: 'Use a concise documentation pass plus public-audit verification.', keywords: ['readme', 'docs', 'install', 'public docs', 'README', '文档', '安装流皋', '普通甚户'], skillNames: ['document-release', 'document-review', 'devex-review'], workflow: [ @@ -103,11 +121,34 @@ export const COMBOS: ComboTemplate[] = [ verification: [check('public-audit', 'Public audit passes', 'npm run audit:public')], doneWhen: ['A new user can install, test, troubleshoot, and roll back from the docs alone.'], }, + { + id: 'test_pr_repair', + title: 'Test repair and PR handoff', + category: 'code-quality', + description: 'Fix failing tests, verify the change, and prepare a pull request handoff.', + entryCommand: 'lazybrain route ""', + executionMode: 'guided', + modelStrategy: 'Use QA/work mode: reproduce the failing test, make the smallest fix, then prepare PR evidence.', + keywords: ['failing tests', 'fix failing tests', 'failed tests', 'test failure', 'create a pr', 'pull request', '修测试', '修倱莥测试', '倱莥测试', '测试倱莥', '匀 PR', '创建 PR', '提亀 PR'], + skillNames: ['ai-regression-testing', 'github-ops', 'project-session-manager'], + workflow: [ + step('reproduce-failing-test', 'Reproduce the failing test or CI failure'), + step('fix-smallest-surface', 'Fix the smallest responsible code path'), + step('verify-pr-evidence', 'Run focused tests and prepare PR evidence'), + ], + contextNeeded: ['Failing test command or CI output', 'Changed branch or diff', 'Expected behavior', 'PR target branch'], + guardrails: [guard('Do not broaden the PR beyond the failing behavior', undefined, 'strict')], + verification: [check('focused-tests', 'Focused failing tests pass'), check('full-tests', 'Automated tests pass', 'npm test')], + doneWhen: ['The original failing test passes.', 'The PR handoff includes what changed and which verification ran.'], + }, { id: 'code_review_regression', title: 'Regression code review', category: 'code-quality', description: 'Review changed code for behavioral regressions and missing tests.', + entryCommand: 'lazybrain route ""', + executionMode: 'advisory', + modelStrategy: 'Use review mode: inspect behavior first, then tests and risk.', keywords: ['review', 'regression', 'risk', '审查', '回園', '风险', '代码审栞'], skillNames: ['ce:review', 'ai-regression-testing', 'coding-standards'], workflow: [ @@ -125,6 +166,9 @@ export const COMBOS: ComboTemplate[] = [ title: 'Stuck runtime debug', category: 'debugging', description: 'Diagnose a long-running or hung local runtime without destructive resets.', + entryCommand: 'lazybrain route ""', + executionMode: 'guided', + modelStrategy: 'Use an evidence-first debugging pass with non-destructive probes.', keywords: ['stuck', 'hung', 'no output', 'debug', '卡䜏', '长时闎无蟓出', '排查', '无响应'], skillNames: ['agent-introspection-debugging', 'omc-doctor', 'debugging'], workflow: [ @@ -137,12 +181,146 @@ export const COMBOS: ComboTemplate[] = [ verification: [check('smoke', 'Smoke test produces real output')], doneWhen: ['The active/stale state is clear and the runtime can be verified with a smoke test.'], }, + { + id: 'debug_crash', + title: 'Crash or bug debug', + category: 'debugging', + description: 'Investigate a bug, crash, failing command, or broken workflow with evidence-first debugging.', + entryCommand: 'lazybrain route ""', + executionMode: 'guided', + modelStrategy: 'Use a debugging-capable model; collect reproduction evidence before editing.', + keywords: ['bug', 'crash', 'error', 'failed', 'failing', 'broken', '报错', '厩溃', '倱莥', '䞍工䜜', '修䞍奜', '匂垞'], + skillNames: ['agent-introspection-debugging', 'debugging', 'ai-regression-testing'], + workflow: [ + step('reproduce-failure', 'Reproduce the failure and capture the exact error'), + step('trace-cause', 'Trace the failing path to the smallest responsible change'), + step('apply-fix', 'Apply a scoped fix without unrelated refactors'), + step('verify-regression', 'Run the failing case plus the nearest regression check'), + ], + contextNeeded: ['Error output', 'Command or workflow that fails', 'Expected behavior', 'Recent related changes'], + guardrails: [guard('Do not guess a fix before reproducing or locating evidence', undefined, 'strict')], + verification: [check('repro-case', 'Original failing case passes'), check('tests', 'Focused tests pass')], + doneWhen: ['The original failure is reproduced, fixed, and verified with a focused check.'], + }, + { + id: 'refactor_clean', + title: 'Refactor and clean code', + category: 'code-quality', + description: 'Clean messy, duplicated, or AI-generated code while preserving behavior.', + entryCommand: 'lazybrain route ""', + executionMode: 'guided', + modelStrategy: 'Use a conservative implementation pass, then verify behavior and tests.', + keywords: ['refactor', 'cleanup', 'clean up', 'simplify', 'slop', 'ai-generated', 'ai generated', '重构', '枅理', '敎理', '凜数', '代码倪乱', '垃土代码', '臃肿', '重倍代码', 'AI 生成'], + negativeKeywords: ['眑页', '页面', '界面', 'ui', 'redesign', '视觉'], + skillNames: ['ai-slop-cleaner', 'coding-standards', 'ai-regression-testing'], + workflow: [ + step('identify-behavior-boundary', 'Identify behavior that must stay unchanged'), + step('remove-noise', 'Remove duplication, dead branches, and unclear generated code'), + step('tighten-types', 'Tighten names, types, and boundaries without broad rewrites'), + step('verify-behavior', 'Run focused checks for the touched surface'), + ], + contextNeeded: ['Target files or module', 'Behavior that must not change', 'Relevant tests or manual check'], + guardrails: [guard('Preserve external behavior; do not combine refactor with feature work', undefined, 'strict')], + verification: [check('tests', 'Focused tests pass'), check('lint', 'Lint/typecheck passes', 'npm run lint')], + doneWhen: ['The code is simpler and behavior is verified unchanged.'], + }, + { + id: 'audit_security', + title: 'Security audit', + category: 'security', + description: 'Audit authentication, authorization, secrets, and vulnerability-sensitive code paths.', + entryCommand: 'lazybrain route ""', + executionMode: 'guided', + modelStrategy: 'Use a high-precision review pass; require evidence for every finding.', + keywords: ['security', 'vulnerability', 'secret', 'auth', 'permission', '安党', '挏掞', '密钥', '讀证', '鉎权', '权限', '合规'], + skillNames: ['security-reviewer', 'django-security', 'laravel-security'], + workflow: [ + step('map-trust-boundary', 'Map the trust boundary and protected assets'), + step('inspect-sensitive-paths', 'Inspect auth, permissions, input handling, and secret exposure'), + step('prioritize-findings', 'Prioritize exploitable findings over generic hardening'), + step('verify-fixes', 'Verify fixes with targeted tests or manual abuse cases'), + ], + contextNeeded: ['Threat surface', 'Auth model', 'Sensitive files or endpoints', 'Expected access rules'], + guardrails: [guard('Do not report speculative vulnerabilities without an exploitable path', undefined, 'strict')], + verification: [check('security-case', 'Abuse case is blocked'), check('tests', 'Relevant tests pass')], + doneWhen: ['Security findings are evidence-backed, prioritized, and verified after fixes.'], + }, + { + id: 'product_direction_planning', + title: 'Product direction planning', + category: 'planning', + description: 'Re-plan product direction and execution strategy before implementation.', + entryCommand: 'lazybrain route ""', + executionMode: 'advisory', + modelStrategy: 'Use product office-hours mode: clarify the audience, wedge, proof, and next execution loop.', + keywords: ['product direction', 'product strategy', 'roadmap', 'execution plan', '重新规划', '产品方向', '產品方向', '执行方案', '執行方案', '规划产品', '芏劃產品', 'office hours'], + skillNames: ['office-hours', 'plan-ceo-review', 'product-capability'], + workflow: [ + step('identify-user-and-pain', 'Identify the target user, current pain, and existing workaround'), + step('choose-narrow-wedge', 'Choose the smallest useful wedge to validate next'), + step('define-execution-loop', 'Define the next validation loop and success signal'), + ], + contextNeeded: ['Target audience', 'Current product state', 'What feels not useful yet', '30-day success signal'], + guardrails: [guard('Do not start implementation until the product premise is explicit', undefined, 'strict')], + verification: [check('premise-review', 'Premises and next validation loop are explicit')], + doneWhen: ['The direction names a user, wedge, success signal, and next execution plan.'], + }, + { + id: 'council_escalation', + title: 'Council escalation review', + category: 'planning', + description: 'Use multi-perspective council review for architecture, cost, product, or irreversible tradeoffs.', + entryCommand: 'lazybrain route ""', + executionMode: 'advisory', + modelStrategy: 'Use a strong reasoning model and council mode: compare positions, surface tradeoffs, then decide.', + keywords: [ + 'council', + 'council mode', + 'escalation', + 'tradeoff', + 'trade-off', + 'architecture decision', + 'cost decision', + 'irreversible', + '议䌚', + '議會', + '议䌚暡匏', + '議會暡匏', + '取舍', + '取捚', + '裁决', + '裁決', + '䞍可逆', + '架构决策', + '架構決策', + '成本决策', + '成本決策', + ], + skillNames: ['critic', 'ralplan', 'architect'], + workflow: [ + step('frame-decision', 'Frame the decision, owner, deadline, and constraints'), + step('collect-positions', 'Collect the strongest arguments for each viable option'), + step('stress-tradeoffs', 'Stress architecture, cost, reversibility, and adoption risks'), + step('record-recommendation', 'Record the recommendation, confidence, dissent, and next step'), + ], + contextNeeded: ['Decision question', 'Options under consideration', 'Irreversible risks', 'Cost or timeline constraints', 'Expected decision owner'], + guardrails: [ + guard('Do not execute irreversible actions from a council route', undefined, 'strict'), + guard('Require a decision record with options, tradeoffs, recommendation, dissent, and next step', undefined, 'strict'), + ], + verification: [check('decision-record', 'Decision record includes options, tradeoffs, recommendation, dissent, owner, and next step')], + doneWhen: ['The council output makes a clear recommendation and names the remaining uncertainty.'], + }, { id: 'release_public_audit', title: 'Public release audit', category: 'release', description: 'Prepare a public release with package and privacy checks.', + entryCommand: 'lazybrain route ""', + executionMode: 'guided', + modelStrategy: 'Use a release-gate pass and require package/privacy verification before publish.', keywords: ['release', 'publish', 'npm', 'audit', 'privacy', 'hook', '发垃', '公匀', '隐私', '回滚'], + negativeKeywords: ['api', '接口', '后端', 'backend', 'server', 'k8s', 'docker', '普通郚眲'], skillNames: ['document-release', 'github-ops', 'ci-cd-best-practices'], workflow: [ step('version-consistency', 'Verify package, CLI, health, changelog, and tag version consistency'), @@ -165,20 +343,44 @@ export function listCombos(category?: string): ComboTemplate[] { return COMBOS.filter(combo => combo.category.toLowerCase() === normalized || combo.id.startsWith(normalized)); } +function hasDebugCrashIntent(query: string): boolean { + const hasFailureSignal = /\b(bug|crash|error)\b|报错|厩溃|倱莥|匂垞|䞍工䜜|修䞍奜/i.test(query); + if (!hasFailureSignal) return false; + return /\b(debug|investigate|diagnose|trace|fix)\b|垮查|排查|查䞀䞋|垮我查|定䜍|看例|垮我看/i.test(query) || + /报错|厩溃|匂垞/.test(query); +} + +function hasCreatePrIntent(query: string): boolean { + return /\b(create|open|prepare|make|submit)\s+(a\s+)?(pr|pull request)\b|\b(create|open|prepare|make|submit)\s+.*\bpull request\b|匀\s*pr|创建\s*pr|发\s*pr|提\s*pr|提亀\s*pr/i.test(query); +} + +function comboIntentBoost(combo: ComboTemplate, query: string): number { + if (combo.id === 'debug_crash' && hasDebugCrashIntent(query)) return 0.25; + if (combo.id === 'test_pr_repair' && hasCreatePrIntent(query)) return 0.25; + return 0; +} + export function findCombo(query: string, categories: string[] = []): ComboTemplate | undefined { const q = query.toLowerCase(); const categorySet = new Set(categories.map(c => c.toLowerCase())); let best: { combo: ComboTemplate; score: number } | undefined; for (const combo of COMBOS) { - let score = categorySet.has(combo.category.toLowerCase()) ? 1 : 0; + let keywordScore = 0; for (const keyword of combo.keywords) { - if (q.includes(keyword.toLowerCase())) score += keyword.length > 5 ? 3 : 2; + if (q.includes(keyword.toLowerCase())) keywordScore += keyword.length > 5 ? 3 : 2; } + + if (keywordScore === 0) continue; + + const categoryScore = categorySet.has(combo.category.toLowerCase()) ? 1 : 0; + const normalizedKeywordScore = Math.min(1, keywordScore / 6); + const hasNegativeSignal = combo.negativeKeywords?.some(keyword => q.includes(keyword.toLowerCase())) ?? false; + const score = (categoryScore * 0.6) + (normalizedKeywordScore * 0.4) + comboIntentBoost(combo, q) - (hasNegativeSignal ? 0.5 : 0); if (!best || score > best.score) best = { combo, score }; } - return best && best.score > 0 ? best.combo : undefined; + return best && best.score >= 0.25 ? best.combo : undefined; } export function formatComboList(combos: ComboTemplate[]): string { @@ -187,8 +389,15 @@ export function formatComboList(combos: ComboTemplate[]): string { for (const combo of combos) { lines.push(` ${combo.id} [${combo.category}]`); lines.push(` ${combo.description}`); + lines.push(` Entry: ${combo.entryCommand} (${combo.executionMode})`); lines.push(` Skills: ${combo.skillNames.join(', ')}`); lines.push(''); } return lines.join('\n').trimEnd(); } + +export function formatComboEntryCommand(combo: ComboTemplate, target: RouteTarget = 'generic'): string { + return target === 'generic' + ? combo.entryCommand + : `${combo.entryCommand} --target ${target}`; +} diff --git a/src/compiler/compile-errors.ts b/src/compiler/compile-errors.ts new file mode 100644 index 0000000..2a3fdc2 --- /dev/null +++ b/src/compiler/compile-errors.ts @@ -0,0 +1,40 @@ +export interface CompileErrorSummary { + total: number; + byCode: Record; +} + +export function classifyCompileError(error: string): string { + const match = /^([a-z0-9_]+):/i.exec(error.trim()); + return match?.[1] ?? 'unknown'; +} + +export function summarizeCompileErrors(errors: string[]): CompileErrorSummary { + const byCode: Record = {}; + for (const error of errors) { + const code = classifyCompileError(error); + byCode[code] = (byCode[code] ?? 0) + 1; + } + return { total: errors.length, byCode }; +} + +export function formatCompileErrorReport(errors: string[], limit = 20): string { + if (errors.length === 0) return 'No persisted compile errors.'; + + const summary = summarizeCompileErrors(errors); + const lines = [ + `Persisted compile errors: ${summary.total}`, + '', + 'By type:', + ]; + for (const [code, count] of Object.entries(summary.byCode).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))) { + lines.push(` - ${code}: ${count}`); + } + + lines.push('', `First ${Math.min(limit, errors.length)} errors:`); + for (const error of errors.slice(0, limit)) { + lines.push(` - ${error}`); + } + if (errors.length > limit) lines.push(` ... ${errors.length - limit} more`); + + return lines.join('\n'); +} diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 8581aef..97e225f 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -10,13 +10,12 @@ import { createHash } from 'node:crypto'; import type { RawCapability, - Capability, - Link, LLMProvider, - LLMResponse, } from '../types.js'; +import { isLinkType } from '../types.js'; import { CATEGORIES, GRAPH_VERSION } from '../constants.js'; import { Graph } from '../graph/graph.js'; +import { inferCapabilityConflictGroup, inferCapabilityProvider, inferCapabilitySideEffects } from '../diagnostics/conflicts.js'; /** Generate deterministic capability ID with optional platform prefix */ export function makeCapabilityId(kind: string, name: string, origin: string, platform?: string): string { @@ -37,7 +36,27 @@ function getSystemPrompt(config?: { compileSystemPrompt?: string }): string { return config?.compileSystemPrompt || DEFAULT_SYSTEM_PROMPT; } -function makeTagPrompt(cap: RawCapability): string { +function renderPromptTemplate(template: string, values: Record): string { + return template.replace(/\$\{([A-Za-z0-9_.]+)\}|\{([A-Za-z0-9_.]+)\}/g, (match, dollarKey: string | undefined, braceKey: string | undefined) => { + const key = dollarKey ?? braceKey; + return key && values[key] !== undefined ? values[key] : match; + }); +} + +function makeTagPrompt(cap: RawCapability, config?: { compileTagPrompt?: string }): string { + if (config?.compileTagPrompt?.trim()) { + return renderPromptTemplate(config.compileTagPrompt, { + name: cap.name, + kind: cap.kind, + description: cap.description, + origin: cap.origin, + filePath: cap.filePath, + compatibility: cap.compatibility.join(', '), + triggers: (cap.triggers ?? []).join(', '), + categories: CATEGORIES.join(', '), + }); + } + return `Analyze this AI coding agent capability and generate metadata. Name: ${cap.name} @@ -47,12 +66,14 @@ ${cap.triggers?.length ? `Triggers: ${cap.triggers.join(', ')}` : ''} Respond with JSON: { - "tags": ["keyword1", "keyword2", ...], // 8-15 semantic tags (include Chinese if description has CJK) - "exampleQueries": ["query1", "query2", ...], // 5-8 example user queries that should match this (mix languages) + "tags": ["keyword1", "keyword2"], + "exampleQueries": ["query1", "query2"], "category": "one-of: ${CATEGORIES.join(', ')}", "scenario": "one sentence: when a user should use this", "explanation_template": "Chinese template explaining why this tool matches: {query_tags} {history_hint} {tool_name}" -}`; +} + +Return only valid JSON. Do not include comments, markdown fences, or extra text.`; } function makeBatchTagPrompt(caps: RawCapability[]): string { @@ -84,11 +105,30 @@ Respond with a JSON array (one object per capability, in order): function makeRelationPrompt( cap: RawCapability, neighbors: Array<{ name: string; description: string }>, + config?: { compileRelationPrompt?: string }, ): string { const neighborList = neighbors .map(n => ` - ${n.name}: ${n.description}`) .join('\n'); + if (config?.compileRelationPrompt?.trim()) { + return renderPromptTemplate(config.compileRelationPrompt, { + name: cap.name, + kind: cap.kind, + description: cap.description, + origin: cap.origin, + filePath: cap.filePath, + compatibility: cap.compatibility.join(', '), + triggers: (cap.triggers ?? []).join(', '), + 'cap.name': cap.name, + 'cap.kind': cap.kind, + 'cap.description': cap.description, + neighbors: neighborList, + neighborList, + categories: CATEGORIES.join(', '), + }); + } + return `Given this capability and a list of other capabilities, identify relationships. This capability: @@ -109,7 +149,8 @@ For each relationship found, respond with JSON array: } ] -Only include relationships with confidence >= 0.6. Return [] if none found.`; +Only include relationships with confidence >= 0.6. Return [] if none found. +Return only valid JSON. Do not include comments, markdown fences, or extra text.`; } // ─── Compiler ───────────────────────────────────────────────────────────── @@ -146,6 +187,14 @@ export interface CompileOptions { onRelationProgress?: (current: number, total: number) => void; } +function isRelationCompileError(error: string): boolean { + return error.startsWith('relation_'); +} + +function mergeCompileErrors(currentErrors: string[], preservedErrors: string[]): string[] { + return [...new Set([...currentErrors, ...preservedErrors])]; +} + export async function compile( rawCapabilities: RawCapability[], options: CompileOptions, @@ -161,6 +210,9 @@ export async function compile( const errors: string[] = []; let progressCount = 0; const newlyCompiledIds: string[] = []; + const preservedRelationErrors = existingGraph && (skipRelations || !forceRelations) + ? existingGraph.getCompileErrors().filter(isRelationCompileError) + : []; // Phase 1: Enrich each capability with tags, example queries, category // Filter out already-compiled nodes first @@ -195,7 +247,7 @@ export async function compile( const isFirst = i === 0 && chunk[0].raw === raw; try { - const prompt = makeTagPrompt(raw); + const prompt = makeTagPrompt(raw, options.config); const response = await llm.complete(prompt, getSystemPrompt(options.config)); totalTokens.input += response.inputTokens; totalTokens.output += response.outputTokens; @@ -218,6 +270,9 @@ export async function compile( name: raw.name, description: raw.description, origin: raw.origin, + provider: inferCapabilityProvider(raw), + conflictGroup: inferCapabilityConflictGroup(raw), + sideEffects: inferCapabilitySideEffects(raw), status: raw.disabled ? 'disabled' : 'installed', compatibility: raw.compatibility, filePath: raw.filePath, @@ -245,6 +300,9 @@ export async function compile( name: raw.name, description: raw.description, origin: raw.origin, + provider: inferCapabilityProvider(raw), + conflictGroup: inferCapabilityConflictGroup(raw), + sideEffects: inferCapabilitySideEffects(raw), status: raw.disabled ? 'disabled' : 'installed', compatibility: raw.compatibility, filePath: raw.filePath, @@ -269,7 +327,9 @@ export async function compile( // Only process tier 0+1 nodes for relations; tier 2 is skipped for speed // If forceRelations is false, only process newly compiled nodes (incremental mode) if (skipRelations) { - return { graph, compiled, skipped, errors, totalTokens }; + const finalErrors = mergeCompileErrors(errors, preservedRelationErrors); + graph.setCompileInfo(modelName, finalErrors); + return { graph, compiled, skipped, errors: finalErrors, totalTokens }; } const allNodes = graph.getAllNodes(); @@ -279,12 +339,14 @@ export async function compile( // Skip Phase 2 if no new nodes to process if (relationNodes.length === 0) { + const finalErrors = mergeCompileErrors(errors, preservedRelationErrors); + graph.setCompileInfo(modelName, finalErrors); return { graph, compiled, skipped, totalTokens, - errors, + errors: finalErrors, }; } @@ -305,53 +367,67 @@ export async function compile( const prompt = makeRelationPrompt( { kind: node.kind, name: node.name, description: node.description, origin: node.origin, filePath: node.filePath ?? '', compatibility: node.compatibility, triggers: node.triggers }, candidates.map(c => ({ name: c.name, description: c.description })), + options.config, ); const response = await llm.complete(prompt, getSystemPrompt(options.config)); totalTokens.input += response.inputTokens; totalTokens.output += response.outputTokens; const relations = parseJsonResponse>(response.content); if (!relations) { - errors.push(`relation:${node.id}: failed to parse LLM response`); + errors.push(`relation_parse_failed:${node.name}:${node.id}: failed to parse LLM response`); return { nodeId: node.id, relations: [] }; } - return { nodeId: node.id, relations: Array.isArray(relations) ? relations : [] }; + if (!Array.isArray(relations)) { + errors.push(`relation_invalid_shape:${node.name}:${node.id}: relation response must be an array`); + return { nodeId: node.id, relations: [] }; + } + + return { nodeId: node.id, relations }; }), ); - for (const result of results) { + for (let resultIndex = 0; resultIndex < results.length; resultIndex++) { + const result = results[resultIndex]; if (result.status === 'rejected') { - const batchIndex = results.indexOf(result); - const failedNode = batch[batchIndex]; + const failedNode = batch[resultIndex]; const errMsg = result.reason instanceof Error ? result.reason.message : String(result.reason); - errors.push(`relation:${failedNode?.name ?? '?'}: ${errMsg}`); + errors.push(`relation_call_failed:${failedNode?.name ?? '?'}:${failedNode?.id ?? '?'}: ${errMsg}`); continue; } if (result.status !== 'fulfilled') continue; const val = result.value; if (!val || Array.isArray(val)) continue; - const { nodeId, relations } = val as { nodeId: string; relations: Array<{ target: string; type: string; description?: string; diff?: string; confidence: number }> }; - for (const rel of relations.filter(r => r.target && r.type && typeof r.confidence === 'number')) { + const { nodeId, relations } = val as { nodeId: string; relations: Array<{ target?: unknown; type?: unknown; description?: unknown; diff?: unknown; confidence?: unknown }> }; + for (const rel of relations) { + if (typeof rel.target !== 'string' || typeof rel.type !== 'string' || typeof rel.confidence !== 'number') { + errors.push(`relation_invalid_shape:${nodeId}: missing target/type/confidence`); + continue; + } + if (rel.confidence < 0.6) continue; + if (!isLinkType(rel.type)) { + errors.push(`relation_invalid_type:${nodeId}->${rel.target}: ${rel.type}`); + continue; + } const targetNode = graph.findByName(rel.target); if (!targetNode) { - process.stderr.write(`[DEBUG] relation:${nodeId}->${rel.target}: target not found\n`); + errors.push(`relation_target_missing:${nodeId}->${rel.target}`); continue; } - if (rel.confidence < 0.6) continue; graph.addLink({ source: nodeId, target: targetNode.id, - type: rel.type as Link['type'], - description: rel.description, - diff: rel.diff, + type: rel.type, + description: typeof rel.description === 'string' ? rel.description : undefined, + diff: typeof rel.diff === 'string' ? rel.diff : undefined, confidence: rel.confidence, }); } @@ -361,24 +437,133 @@ export async function compile( onRelationProgress?.(relationCount, relationNodes.length); } - graph.setCompileInfo(modelName); - return { graph, totalTokens, compiled, skipped, errors }; + const finalErrors = mergeCompileErrors(errors, preservedRelationErrors); + graph.setCompileInfo(modelName, finalErrors); + return { graph, totalTokens, compiled, skipped, errors: finalErrors }; } // ─── Helpers ────────────────────────────────────────────────────────────── function parseJsonResponse(content: string): T | null { - try { - // Strip ... blocks (closed or truncated/unclosed) - const cleaned = content - .replace(/[\s\S]*?<\/think>/g, '') - .replace(/[\s\S]*/g, '') - .replace(/^```(?:json)?\s*/m, '') - .replace(/\s*```\s*$/m, '') - .trim(); - if (!cleaned) return null; - return JSON.parse(cleaned) as T; - } catch { - return null; + // Strip ... blocks (closed or truncated/unclosed). + const cleaned = content + .replace(/[\s\S]*?<\/think>/g, '') + .replace(/[\s\S]*/g, '') + .trim(); + if (!cleaned) return null; + + const candidates = [cleaned, extractJsonCandidate(cleaned)] + .filter((value): value is string => Boolean(value?.trim())); + + for (const candidate of candidates) { + const normalized = normalizeJsonCandidate(candidate); + try { + return JSON.parse(normalized) as T; + } catch {} } + + return null; +} + +function normalizeJsonCandidate(content: string): string { + const withoutFences = content + .replace(/^\s*```(?:json)?\s*/i, '') + .replace(/\s*```\s*$/i, '') + .trim(); + return stripJsonComments(withoutFences).replace(/,\s*([}\]])/g, '$1').trim(); +} + +function extractJsonCandidate(content: string): string | null { + const start = findFirstJsonStart(content); + if (start < 0) return null; + + const open = content[start]; + const close = open === '{' ? '}' : ']'; + const stack: string[] = []; + let inString = false; + let escaped = false; + + for (let i = start; i < content.length; i++) { + const char = content[i]; + + if (inString) { + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + if (char === '{' || char === '[') { + stack.push(char === '{' ? '}' : ']'); + continue; + } + if (char === '}' || char === ']') { + if (stack.pop() !== char) return null; + if (stack.length === 0 && char === close) return content.slice(start, i + 1); + } + } + + return null; +} + +function findFirstJsonStart(content: string): number { + const objectStart = content.indexOf('{'); + const arrayStart = content.indexOf('['); + if (objectStart < 0) return arrayStart; + if (arrayStart < 0) return objectStart; + return Math.min(objectStart, arrayStart); +} + +function stripJsonComments(content: string): string { + let result = ''; + let inString = false; + let escaped = false; + + for (let i = 0; i < content.length; i++) { + const char = content[i]; + const next = content[i + 1]; + + if (inString) { + result += char; + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + result += char; + continue; + } + + if (char === '/' && next === '/') { + while (i < content.length && content[i] !== '\n') i++; + result += '\n'; + continue; + } + + if (char === '/' && next === '*') { + i += 2; + while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) i++; + i++; + continue; + } + + result += char; + } + + return result; } diff --git a/src/compiler/llm-provider.ts b/src/compiler/llm-provider.ts index b3112b1..2cdfd26 100644 --- a/src/compiler/llm-provider.ts +++ b/src/compiler/llm-provider.ts @@ -15,20 +15,26 @@ export class OpenAICompatibleProvider implements LLMProvider { private model: string; private apiBase: string; private apiKey: string; + private maxTokens: number; constructor(config: LLMProviderConfig) { this.model = config.model; this.apiBase = config.apiBase.replace(/\/$/, ''); this.apiKey = config.apiKey ?? ''; + this.maxTokens = config.maxTokens ?? parseInt(process.env.LAZYBRAIN_COMPILE_MAX_TOKENS || '2048', 10); } async complete(prompt: string, systemPrompt?: string, options?: { signal?: AbortSignal }): Promise { const messages: Array<{ role: string; content: string }> = []; if (systemPrompt) { - messages.push({ role: 'system', content: systemPrompt }); + messages.push({ + role: 'system', + content: `${systemPrompt}\nDo not include blocks. Output the final JSON immediately.`, + }); } - // Qwen 暡型需芁 /no_think 前猀关闭思考暡匏 - const noThinkPrefix = this.model.toLowerCase().includes('qwen') ? '/no_think\n\n' : ''; + // Some reasoning models accept /no_think but may still emit a short ; + // a larger token cap keeps the final JSON from being truncated. + const noThinkPrefix = /qwen|minimax|m2\.?7|deepseek/i.test(this.model) ? '/no_think\n\n' : ''; messages.push({ role: 'user', content: noThinkPrefix + prompt }); const res = await fetch(`${this.apiBase}/chat/completions`, { @@ -42,7 +48,7 @@ export class OpenAICompatibleProvider implements LLMProvider { model: this.model, messages, temperature: 0.3, - max_tokens: 512, + max_tokens: this.maxTokens, }), signal: options?.signal, }); diff --git a/src/compiler/relation-inferrer.ts b/src/compiler/relation-inferrer.ts index eb41df2..0bea726 100644 --- a/src/compiler/relation-inferrer.ts +++ b/src/compiler/relation-inferrer.ts @@ -5,6 +5,7 @@ */ import type { LLMProvider, RawCapability, LinkType } from '../types.js'; +import { isLinkType } from '../types.js'; export interface InferredRelation { targetName: string; @@ -61,14 +62,6 @@ function parseJsonResponse(content: string): T | null { } } -const VALID_TYPES: LinkType[] = [ - 'similar_to', - 'composes_with', - 'supersedes', - 'depends_on', - 'belongs_to', -]; - export async function inferRelations( cap: RawCapability, candidates: Array<{ name: string; description: string }>, @@ -104,12 +97,11 @@ export async function inferRelations( if (item.confidence < 0.6) continue; - const type = item.type as string; - if (!VALID_TYPES.includes(type as LinkType)) continue; + if (!isLinkType(item.type)) continue; relations.push({ targetName: item.target, - type: type as LinkType, + type: item.type, description: item.description, diff: typeof item.diff === 'string' ? item.diff : undefined, confidence: item.confidence, diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..645b7fe --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,79 @@ +export const CONFIG_ALLOWED_KEYS = new Set([ + 'compileApiBase', 'compileApiKey', 'compileModel', + 'embeddingApiBase', 'embeddingApiKey', 'embeddingModel', 'embeddingSource', + 'secretaryApiBase', 'secretaryApiKey', 'secretaryModel', + 'engine', 'strategy', 'mode', 'autoThreshold', 'language', + 'compileSystemPrompt', 'compileTagPrompt', 'compileRelationPrompt', +]); + +export const VALID_ENGINES = new Set(['tag', 'semantic', 'hybrid', 'llm']); +export const VALID_STRATEGIES = new Set(['always-main', 'optimal', 'ask']); +export const VALID_MODES = new Set(['auto', 'select', 'ask']); +export const VALID_LANGUAGES = new Set(['auto', 'en', 'zh']); +export const VALID_EMBEDDING_SOURCES = new Set(['api', 'custom', 'local']); +export const SECRET_CONFIG_KEYS = new Set(['compileApiKey', 'embeddingApiKey', 'secretaryApiKey']); + +export type ConfigUpdatePatch = Record; + +export type ConfigUpdateValidation = + | { ok: true; patch: ConfigUpdatePatch; ignoredKeys: string[] } + | { ok: false; error: string }; + +export function sanitizeConfigUpdate(body: Record): { patch: ConfigUpdatePatch; ignoredKeys: string[] } { + const patch: ConfigUpdatePatch = {}; + const ignoredKeys: string[] = []; + for (const [key, value] of Object.entries(body)) { + if (SECRET_CONFIG_KEYS.has(key) && typeof value === 'string' && value.trim() === '') { + ignoredKeys.push(key); + continue; + } + patch[key] = value; + } + return { patch, ignoredKeys }; +} + +function formatAllowed(values: Set): string { + return [...values].join(', '); +} + +function validateEnum(key: string, value: unknown, values: Set): string | null { + if (typeof value !== 'string' || !values.has(value)) { + return `Invalid ${key}. Must be one of: ${formatAllowed(values)}`; + } + return null; +} + +export function validateConfigUpdate(body: Record): ConfigUpdateValidation { + for (const key of Object.keys(body)) { + if (!CONFIG_ALLOWED_KEYS.has(key)) { + return { ok: false, error: `Unknown config key: ${key}` }; + } + } + + for (const [key, value] of Object.entries(body)) { + if (key === 'autoThreshold') { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) { + return { ok: false, error: 'autoThreshold must be a finite number between 0 and 1' }; + } + } else if (key === 'engine') { + const error = validateEnum(key, value, VALID_ENGINES); + if (error) return { ok: false, error }; + } else if (key === 'strategy') { + const error = validateEnum(key, value, VALID_STRATEGIES); + if (error) return { ok: false, error }; + } else if (key === 'mode') { + const error = validateEnum(key, value, VALID_MODES); + if (error) return { ok: false, error }; + } else if (key === 'language') { + const error = validateEnum(key, value, VALID_LANGUAGES); + if (error) return { ok: false, error }; + } else if (key === 'embeddingSource') { + const error = validateEnum(key, value, VALID_EMBEDDING_SOURCES); + if (error) return { ok: false, error }; + } else if (typeof value !== 'string') { + return { ok: false, error: `config key "${key}" must be a string` }; + } + } + + return { ok: true, ...sanitizeConfigUpdate(body) }; +} diff --git a/src/constants.ts b/src/constants.ts index 65ceee1..d15bdc9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -23,6 +23,8 @@ export const WIKI_DIR = join(LAZYBRAIN_DIR, 'wiki'); export const EXTERNAL_CATALOG_PATH = join(LAZYBRAIN_DIR, 'external-catalog.json'); export const PROFILE_PATH = join(LAZYBRAIN_DIR, 'profile.json'); export const ROUTE_EVENTS_PATH = join(LAZYBRAIN_DIR, 'route-events.jsonl'); +export const JOBS_DIR = join(LAZYBRAIN_DIR, 'jobs'); +export const JOBS_LATEST_PATH = join(JOBS_DIR, 'latest.json'); /** OMC state directory — read to detect active execution mode */ export const OMC_STATE_DIR = join(homedir(), '.omc', 'state'); @@ -86,6 +88,7 @@ export function getDefaultScanPaths(platforms?: Record): string join(claude, 'ecc', '.cursor', 'skills'), join(claude, 'ecc', '.kiro', 'skills'), join(claude, 'plugins'), + join(home, '.skillshub'), ); } diff --git a/src/diagnostics/conflicts.ts b/src/diagnostics/conflicts.ts new file mode 100644 index 0000000..22ad1ca --- /dev/null +++ b/src/diagnostics/conflicts.ts @@ -0,0 +1,143 @@ +import type { Capability, CapabilitySideEffect, RawCapability } from '../types.js'; + +export interface CapabilityConflictDiagnostic { + group: string; + winner: string; + suppressed: string[]; + severity: 'info' | 'warn' | 'block'; + reason: string; + suggestedAction?: string; +} + +type ConflictInput = Pick; + +function normalize(value: string): string { + return value.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-').replace(/^-+|-+$/g, ''); +} + +export function inferCapabilityProvider(input: Pick): string { + return input.provider?.trim() || input.origin || 'unknown'; +} + +export function inferCapabilityConflictGroup(input: Pick): string { + if (input.conflictGroup?.trim()) return input.conflictGroup.trim(); + return `${input.kind}:${normalize(input.name) || 'unnamed'}`; +} + +export function inferCapabilitySideEffects(input: ConflictInput & { sideEffects?: CapabilitySideEffect[] }): CapabilitySideEffect[] { + if (input.sideEffects?.length) return [...new Set(input.sideEffects)]; + const text = `${input.name} ${input.description} ${input.filePath} ${(input.triggers ?? []).join(' ')}`.toLowerCase(); + const effects = new Set(); + + if (/read|scan|search|inspect|review|audit|analy[sz]e|查看|扫描|搜玢|审查|检查/.test(text)) effects.add('reads_files'); + if (/write|edit|patch|create|generate|save|update|修改|写入|创建|生成|保存|曎新/.test(text)) effects.add('writes_files'); + if (/run|exec|command|shell|terminal|build|test|lint|执行|呜什|终端|构建|测试/.test(text)) effects.add('executes_commands'); + if (/api|http|network|browser|web|fetch|download|upload|眑络|浏览噚|䞋蜜|䞊䌠/.test(text)) effects.add('network'); + if (/config|settings|hook|statusline|mcp|配眮|讟眮|钩子/.test(text)) effects.add('changes_config'); + if (/hook|statusline|userpromptsubmit|sessionstart|stop|钩子/.test(text)) effects.add('installs_hooks'); + if (/publish|release|deploy|npm|pypi|production|prod|发垃|郚眲|生产/.test(text)) effects.add('publishes'); + if (/delete|remove|reset|force|destructive|rollback|删陀|重眮|区制|回滚/.test(text)) effects.add('destructive'); + + return effects.size > 0 ? [...effects] : ['unknown']; +} + +function winnerFor(items: Capability[]): Capability { + return [...items].sort((a, b) => { + const priorityA = a.sourcePriority ?? 100; + const priorityB = b.sourcePriority ?? 100; + if (priorityA !== priorityB) return priorityA - priorityB; + if (a.status !== b.status) return a.status === 'installed' ? -1 : 1; + return a.name.localeCompare(b.name); + })[0]; +} + +function normalizedText(value: string | undefined): string { + return (value ?? '').trim().toLowerCase().replace(/\s+/g, ' '); +} + +function significantWords(value: string | undefined): Set { + return new Set( + normalizedText(value) + .split(/[^a-z0-9\u4e00-\u9fff]+/) + .filter(word => word.length >= 4) + ); +} + +function descriptionsEquivalent(items: Capability[]): boolean { + const descriptions = items.map(item => normalizedText(item.description)); + const unique = new Set(descriptions); + if (unique.size <= 1) return true; + + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + const left = significantWords(items[i].description); + const right = significantWords(items[j].description); + if (left.size === 0 || right.size === 0) return false; + const intersection = [...left].filter(word => right.has(word)).length; + const overlap = intersection / Math.min(left.size, right.size); + if (overlap < 0.65) return false; + } + } + return true; +} + +function normalizedName(capability: Capability): string { + return normalize(capability.name); +} + +function sideEffectKey(capability: Capability): string { + return [...new Set(capability.sideEffects ?? [])].sort().join(','); +} + +function hasRiskyRoutingSurface(capability: Capability): boolean { + if (capability.requiresConfirmation || capability.riskLevel === 'destructive') return true; + return (capability.sideEffects ?? []).some(effect => + effect === 'destructive' || + effect === 'publishes' || + effect === 'installs_hooks' || + effect === 'changes_config' + ); +} + +function areEquivalentProviderDuplicates(items: Capability[]): boolean { + const names = new Set(items.map(normalizedName)); + const sideEffects = new Set(items.map(sideEffectKey)); + return names.size === 1 && + descriptionsEquivalent(items) && + sideEffects.size === 1 && + !items.some(hasRiskyRoutingSurface); +} + +export function detectCapabilityConflicts(capabilities: Capability[]): CapabilityConflictDiagnostic[] { + const groups = new Map(); + + for (const capability of capabilities) { + const group = capability.conflictGroup || `${capability.kind}:${normalize(capability.name) || capability.id}`; + const items = groups.get(group) ?? []; + items.push(capability); + groups.set(group, items); + } + + const conflicts: CapabilityConflictDiagnostic[] = []; + for (const [group, items] of groups) { + if (items.length < 2) continue; + const providers = new Set(items.map(item => inferCapabilityProvider(item))); + if (providers.size < 2) continue; + const winner = winnerFor(items); + const equivalent = areEquivalentProviderDuplicates(items); + conflicts.push({ + group, + winner: winner.id, + suppressed: items.filter(item => item.id !== winner.id).map(item => item.id), + severity: equivalent ? 'info' : 'warn', + reason: equivalent + ? `Multiple providers expose equivalent ${group}; route will use the winner and keep duplicate providers as alternatives.` + : `Multiple providers expose ${group}; route should rank one winner and keep the rest as alternatives.`, + suggestedAction: equivalent + ? 'No action required. Keep the selected winner and leave equivalent duplicate providers available as alternatives.' + : 'Choose one primary provider by sourcePriority or explicit conflictGroup metadata before chaining providers with different behavior.', + }); + } + + return conflicts.sort((a, b) => a.group.localeCompare(b.group)); +} diff --git a/src/embeddings/cache.ts b/src/embeddings/cache.ts index 6de6ec6..9d3b558 100644 --- a/src/embeddings/cache.ts +++ b/src/embeddings/cache.ts @@ -3,6 +3,25 @@ import { EMBEDDINGS_BIN_PATH, EMBEDDINGS_INDEX_PATH, EMBEDDINGS_STATUS_PATH } fr import type { Capability } from '../types.js'; export type EmbeddingCacheState = 'missing' | 'ok' | 'stale' | 'invalid'; +export type EmbeddingEntryStatus = 'fresh' | 'stale' | 'missing'; + +export interface EmbeddingCacheEntryMeta { + contentHash?: string; + provider?: string; + model?: string; + dim?: number; + updatedAt?: string; + status?: EmbeddingEntryStatus; +} + +export interface EmbeddingStatusFile { + updatedAt?: string; + indexed?: number; + dim?: number; + provider?: string; + model?: string; + entries?: Record; +} export interface EmbeddingCacheStatus { state: EmbeddingCacheState; @@ -12,12 +31,26 @@ export interface EmbeddingCacheStatus { active: number; covered: number; coverage: number; + coveragePercent: number; + missingIds: string[]; dim: number | null; bytes: number; + provider?: string; + model?: string; updatedAt?: string; message: string; } +export function readEmbeddingStatusFile(): EmbeddingStatusFile | null { + if (!existsSync(EMBEDDINGS_STATUS_PATH)) return null; + try { + const raw = JSON.parse(readFileSync(EMBEDDINGS_STATUS_PATH, 'utf-8')) as EmbeddingStatusFile; + return raw && typeof raw === 'object' ? raw : null; + } catch { + return null; + } +} + function readIndex(): string[] | null { try { const raw = JSON.parse(readFileSync(EMBEDDINGS_INDEX_PATH, 'utf-8')) as unknown; @@ -28,12 +61,8 @@ function readIndex(): string[] | null { } function readUpdatedAt(): string | undefined { - if (existsSync(EMBEDDINGS_STATUS_PATH)) { - try { - const raw = JSON.parse(readFileSync(EMBEDDINGS_STATUS_PATH, 'utf-8')) as { updatedAt?: unknown }; - if (typeof raw.updatedAt === 'string') return raw.updatedAt; - } catch {} - } + const status = readEmbeddingStatusFile(); + if (typeof status?.updatedAt === 'string') return status.updatedAt; if (existsSync(EMBEDDINGS_BIN_PATH)) { try { return statSync(EMBEDDINGS_BIN_PATH).mtime.toISOString(); @@ -45,6 +74,7 @@ function readUpdatedAt(): string | undefined { export function getEmbeddingCacheStatus(nodes: Capability[], staleThreshold = 0.8): EmbeddingCacheStatus { const indexExists = existsSync(EMBEDDINGS_INDEX_PATH); const binExists = existsSync(EMBEDDINGS_BIN_PATH); + const statusFile = readEmbeddingStatusFile(); const activeIds = new Set(nodes.filter(n => n.status !== 'disabled').map(n => n.id)); const active = activeIds.size; @@ -57,8 +87,12 @@ export function getEmbeddingCacheStatus(nodes: Capability[], staleThreshold = 0. active, covered: 0, coverage: 0, + coveragePercent: 0, + missingIds: [...activeIds], dim: null, bytes: 0, + provider: statusFile?.provider, + model: statusFile?.model, updatedAt: readUpdatedAt(), message: 'Embedding cache is missing.', }; @@ -74,8 +108,12 @@ export function getEmbeddingCacheStatus(nodes: Capability[], staleThreshold = 0. active, covered: 0, coverage: 0, + coveragePercent: 0, + missingIds: [...activeIds], dim: null, bytes: 0, + provider: statusFile?.provider, + model: statusFile?.model, updatedAt: readUpdatedAt(), message: 'Embedding index is unreadable.', }; @@ -93,14 +131,42 @@ export function getEmbeddingCacheStatus(nodes: Capability[], staleThreshold = 0. active, covered: 0, coverage: 0, + coveragePercent: 0, + missingIds: [...activeIds], dim: null, bytes: 0, + provider: statusFile?.provider, + model: statusFile?.model, updatedAt: readUpdatedAt(), message: 'Embedding binary is unreadable.', }; } - const dim = ids.length > 0 ? bytes / Float32Array.BYTES_PER_ELEMENT / ids.length : 0; + if (ids.length === 0) { + const coverage = active > 0 ? 0 : 1; + const state: EmbeddingCacheState = active > 0 ? 'stale' : 'ok'; + return { + state, + indexExists, + binExists, + indexed: 0, + active, + covered: 0, + coverage, + coveragePercent: Math.round(coverage * 100), + missingIds: [...activeIds], + dim: statusFile?.dim && statusFile.dim > 0 ? statusFile.dim : null, + bytes, + provider: statusFile?.provider, + model: statusFile?.model, + updatedAt: readUpdatedAt(), + message: active === 0 + ? 'Embedding cache is empty because there are no active capabilities.' + : `Embedding cache is stale (0/${active} active capabilities covered).`, + }; + } + + const dim = bytes / Float32Array.BYTES_PER_ELEMENT / ids.length; if (!Number.isInteger(dim) || dim <= 0) { return { state: 'invalid', @@ -110,14 +176,20 @@ export function getEmbeddingCacheStatus(nodes: Capability[], staleThreshold = 0. active, covered: 0, coverage: 0, + coveragePercent: 0, + missingIds: [...activeIds], dim: null, bytes, + provider: statusFile?.provider, + model: statusFile?.model, updatedAt: readUpdatedAt(), message: 'Embedding binary has invalid dimensions.', }; } const covered = ids.filter(id => activeIds.has(id)).length; + const idSet = new Set(ids); + const missingIds = [...activeIds].filter(id => !idSet.has(id)); const coverage = active > 0 ? covered / active : 1; const state: EmbeddingCacheState = coverage >= staleThreshold ? 'ok' : 'stale'; return { @@ -128,8 +200,12 @@ export function getEmbeddingCacheStatus(nodes: Capability[], staleThreshold = 0. active, covered, coverage, + coveragePercent: Math.round(coverage * 100), + missingIds, dim, bytes, + provider: statusFile?.provider, + model: statusFile?.model, updatedAt: readUpdatedAt(), message: state === 'ok' ? `Embedding cache covers ${covered}/${active} active capabilities.` diff --git a/src/embeddings/rebuild.ts b/src/embeddings/rebuild.ts index 75eb106..2546ba0 100644 --- a/src/embeddings/rebuild.ts +++ b/src/embeddings/rebuild.ts @@ -1,4 +1,5 @@ -import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { EMBEDDINGS_BIN_PATH, @@ -8,12 +9,23 @@ import { } from '../constants.js'; import type { Capability, UserConfig } from '../types.js'; import { embedTexts, getEmbeddingProviderConfig } from './provider.js'; -import { getEmbeddingCacheStatus, type EmbeddingCacheStatus } from './cache.js'; +import { + getEmbeddingCacheStatus, + readEmbeddingStatusFile, + type EmbeddingCacheEntryMeta, + type EmbeddingCacheStatus, +} from './cache.js'; export interface EmbeddingRebuildResult { ok: boolean; indexed: number; dim: number; + embedded: number; + reused: number; + removed: number; + mode: 'incremental' | 'full'; + provider?: string; + model?: string; status: EmbeddingCacheStatus; error?: string; } @@ -30,6 +42,14 @@ function capabilityText(cap: Capability): string { ].filter(Boolean).join('\n'); } +function capabilityContentHash(cap: Capability): string { + return createHash('sha1').update(capabilityText(cap)).digest('hex'); +} + +function publicProvider(apiBase?: string): string | undefined { + return apiBase?.replace(/\/$/, '').replace(/\/v\d+.*$/, '/v*'); +} + function acquireLock(): boolean { try { mkdirSync(dirname(EMBEDDINGS_LOCK_PATH), { recursive: true }); @@ -44,7 +64,43 @@ function releaseLock(): void { try { rmSync(EMBEDDINGS_LOCK_PATH, { force: true }); } catch {} } -function writeAtomic(indexIds: string[], vectors: number[][]): void { +function readIndex(): string[] { + try { + const raw = JSON.parse(readFileSync(EMBEDDINGS_INDEX_PATH, 'utf-8')) as unknown; + return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string') : []; + } catch { + return []; + } +} + +function readExistingVectors(): { ids: string[]; dim: number; vectorsById: Map } { + if (!existsSync(EMBEDDINGS_INDEX_PATH) || !existsSync(EMBEDDINGS_BIN_PATH)) { + return { ids: [], dim: 0, vectorsById: new Map() }; + } + const ids = readIndex(); + if (ids.length === 0) return { ids: [], dim: 0, vectorsById: new Map() }; + const bin = readFileSync(EMBEDDINGS_BIN_PATH); + const dim = bin.byteLength / Float32Array.BYTES_PER_ELEMENT / ids.length; + if (!Number.isInteger(dim) || dim <= 0) return { ids: [], dim: 0, vectorsById: new Map() }; + const arrayBuffer = bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.byteLength); + const matrix = new Float32Array(arrayBuffer); + const vectorsById = new Map(); + for (let row = 0; row < ids.length; row++) { + const start = row * dim; + vectorsById.set(ids[row], Array.from(matrix.slice(start, start + dim))); + } + return { ids, dim, vectorsById }; +} + +function writeAtomic( + indexIds: string[], + vectors: number[][], + metadata: { + provider?: string; + model?: string; + entries: Record; + }, +): void { const dim = vectors[0]?.length ?? 0; const flat = new Float32Array(indexIds.length * dim); for (let row = 0; row < vectors.length; row++) { @@ -61,6 +117,9 @@ function writeAtomic(indexIds: string[], vectors: number[][]): void { updatedAt: new Date().toISOString(), indexed: indexIds.length, dim, + provider: metadata.provider, + model: metadata.model, + entries: metadata.entries, }, null, 2), 'utf-8'); renameSync(indexTmp, EMBEDDINGS_INDEX_PATH); renameSync(binTmp, EMBEDDINGS_BIN_PATH); @@ -70,41 +129,121 @@ function writeAtomic(indexIds: string[], vectors: number[][]): void { export async function rebuildEmbeddingCache( nodes: Capability[], config: UserConfig, - options: { batchSize?: number } = {}, + options: { batchSize?: number; force?: boolean } = {}, ): Promise { if (!acquireLock()) { const status = getEmbeddingCacheStatus(nodes); - return { ok: false, indexed: status.indexed, dim: status.dim ?? 0, status, error: 'embedding rebuild is already running' }; + return { ok: false, indexed: status.indexed, dim: status.dim ?? 0, embedded: 0, reused: 0, removed: 0, mode: options.force ? 'full' : 'incremental', status, error: 'embedding rebuild is already running' }; } try { const active = nodes.filter(node => node.status !== 'disabled'); if (active.length === 0) { + const provider = getEmbeddingProviderConfig(config); + const providerName = publicProvider(provider.apiBase); + const existing = options.force ? { ids: [], dim: 0, vectorsById: new Map() } : readExistingVectors(); + writeAtomic([], [], { provider: providerName, model: provider.model, entries: {} }); const status = getEmbeddingCacheStatus(nodes); - return { ok: false, indexed: 0, dim: 0, status, error: 'graph has no active capabilities' }; + return { + ok: true, + indexed: 0, + dim: 0, + embedded: 0, + reused: 0, + removed: existing.ids.length, + mode: options.force ? 'full' : 'incremental', + provider: providerName, + model: provider.model, + status, + }; } const batchSize = Math.max(1, Math.min(options.batchSize ?? 32, 128)); + const provider = getEmbeddingProviderConfig(config); + const providerName = publicProvider(provider.apiBase); + const model = provider.model; + const existing = options.force ? { ids: [], dim: 0, vectorsById: new Map() } : readExistingVectors(); + const existingStatus = options.force ? null : readEmbeddingStatusFile(); + const existingEntries = existingStatus?.entries ?? {}; + const activeIds = new Set(active.map(cap => cap.id)); + const removed = existing.ids.filter(id => !activeIds.has(id)).length; + + const planned = active.map(cap => { + const contentHash = capabilityContentHash(cap); + const entry = existingEntries[cap.id]; + const vector = existing.vectorsById.get(cap.id); + const canReuse = Boolean( + !options.force && + vector && + entry?.contentHash === contentHash && + entry?.provider === providerName && + entry?.model === model && + entry?.dim === existing.dim, + ); + return { cap, contentHash, vector: canReuse ? vector : undefined }; + }); + + const toEmbed = planned.filter(item => !item.vector); + const embeddedById = new Map(); + + for (let i = 0; i < toEmbed.length; i += batchSize) { + const batch = toEmbed.slice(i, i + batchSize); + const embedded = await embedTexts(batch.map(item => capabilityText(item.cap)), provider); + for (let j = 0; j < batch.length; j++) { + embeddedById.set(batch[j].cap.id, embedded[j]); + } + } + const vectors: number[][] = []; const ids: string[] = []; - const provider = getEmbeddingProviderConfig(config); + const entries: Record = {}; + const now = new Date().toISOString(); + for (const item of planned) { + const vector = item.vector ?? embeddedById.get(item.cap.id); + if (!vector) throw new Error(`embedding vector missing for ${item.cap.id}`); + vectors.push(vector); + ids.push(item.cap.id); + entries[item.cap.id] = { + contentHash: item.contentHash, + provider: providerName, + model, + dim: vector.length, + updatedAt: item.vector ? existingEntries[item.cap.id]?.updatedAt ?? now : now, + status: 'fresh', + }; + } - for (let i = 0; i < active.length; i += batchSize) { - const batch = active.slice(i, i + batchSize); - const embedded = await embedTexts(batch.map(capabilityText), provider); - vectors.push(...embedded); - ids.push(...batch.map(cap => cap.id)); + const dim = vectors[0]?.length ?? 0; + if (dim <= 0 || vectors.some(vector => vector.length !== dim)) { + throw new Error('embedding vectors have inconsistent dimensions'); } - writeAtomic(ids, vectors); + writeAtomic(ids, vectors, { provider: providerName, model, entries }); const status = getEmbeddingCacheStatus(nodes); - return { ok: true, indexed: ids.length, dim: vectors[0]?.length ?? 0, status }; + return { + ok: true, + indexed: ids.length, + dim, + embedded: toEmbed.length, + reused: planned.length - toEmbed.length, + removed, + mode: options.force ? 'full' : 'incremental', + provider: providerName, + model, + status, + }; } catch (err) { const status = getEmbeddingCacheStatus(nodes); return { ok: false, indexed: status.indexed, dim: status.dim ?? 0, + embedded: 0, + reused: 0, + removed: 0, + mode: options.force ? 'full' : 'incremental', + provider: status.provider, + model: status.model, status, error: err instanceof Error ? err.message : String(err), }; diff --git a/src/graph/graph.ts b/src/graph/graph.ts index 4ccc9ac..17ee53c 100644 --- a/src/graph/graph.ts +++ b/src/graph/graph.ts @@ -60,13 +60,31 @@ import type { LinkType, WikiCard, } from '../types.js'; +import { isLinkType } from '../types.js'; import { GRAPH_PATH, GRAPH_VERSION } from '../constants.js'; +function isCapabilityCostLevel(value: unknown): value is Capability['costLevel'] { + return value === 'free' || value === 'low' || value === 'medium' || value === 'high'; +} + +function isCapabilityRiskLevel(value: unknown): value is Capability['riskLevel'] { + return value === 'safe' || value === 'caution' || value === 'destructive'; +} + +function isBoolean(value: unknown): value is boolean { + return typeof value === 'boolean'; +} + +function isNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + export class Graph { private nodes: Map = new Map(); private adjacency: Map = new Map(); private compileModel?: string; private compiledAt?: string; + private compileErrors: string[] = []; // ─── Load / Save ──────────────────────────────────────────────────────── @@ -83,6 +101,9 @@ export class Graph { name: node.name ?? 'Unnamed', description: node.description ?? '', origin: node.origin ?? 'unknown', + provider: node.provider ?? node.origin ?? 'unknown', + conflictGroup: node.conflictGroup, + sideEffects: Array.isArray(node.sideEffects) ? node.sideEffects : undefined, status: node.status ?? 'installed', compatibility: Array.isArray(node.compatibility) ? node.compatibility : ['universal'], filePath: node.filePath, @@ -90,20 +111,28 @@ export class Graph { exampleQueries: Array.isArray(node.exampleQueries) ? node.exampleQueries : [], category: node.category ?? 'other', scenario: node.scenario, + explanation_template: node.explanation_template, meta: node.meta, triggers: Array.isArray(node.triggers) ? node.triggers : undefined, aliases: Array.isArray(node.aliases) ? node.aliases : undefined, tier: node.tier, evolvedTags: Array.isArray(node.evolvedTags) ? node.evolvedTags : undefined, + costLevel: isCapabilityCostLevel(node.costLevel) ? node.costLevel : undefined, + riskLevel: isCapabilityRiskLevel(node.riskLevel) ? node.riskLevel : undefined, + requiresConfirmation: isBoolean(node.requiresConfirmation) ? node.requiresConfirmation : undefined, + hiddenByDefault: isBoolean(node.hiddenByDefault) ? node.hiddenByDefault : undefined, + sourcePriority: isNumber(node.sourcePriority) ? node.sourcePriority : undefined, + overlapsWith: Array.isArray(node.overlapsWith) ? node.overlapsWith.filter((name): name is string => typeof name === 'string') : undefined, schema: node.schema, }; g.nodes.set(validNode.id, validNode); } for (const link of raw.links ?? []) { - g.addLinkInternal(link); + if (isLinkType(link.type)) g.addLinkInternal(link); } g.compileModel = raw.compileModel; g.compiledAt = raw.compiledAt; + g.compileErrors = Array.isArray(raw.compileErrors) ? raw.compileErrors.filter((error): error is string => typeof error === 'string') : []; return g; }); } @@ -118,6 +147,7 @@ export class Graph { version: GRAPH_VERSION, compiledAt: this.compiledAt ?? new Date().toISOString(), compileModel: this.compileModel, + compileErrors: this.compileErrors, nodes, links: this.getAllLinks(), categories: [...new Set(nodes.map(n => n.category))].sort(), @@ -171,6 +201,7 @@ export class Graph { addLink(link: Link): void { if (!this.nodes.has(link.source) || !this.nodes.has(link.target)) return; + if (!isLinkType(link.type)) return; this.addLinkInternal(link); } @@ -346,8 +377,13 @@ export class Graph { // ─── Metadata ───────────────────────────────────────────────────────── - setCompileInfo(model: string): void { + setCompileInfo(model: string, errors: string[] = []): void { this.compileModel = model; this.compiledAt = new Date().toISOString(); + this.compileErrors = [...errors]; + } + + getCompileErrors(): string[] { + return [...this.compileErrors]; } } diff --git a/src/health/api-test.ts b/src/health/api-test.ts index c429f9d..cb2a7ec 100644 --- a/src/health/api-test.ts +++ b/src/health/api-test.ts @@ -10,6 +10,8 @@ export interface ApiTestResult { apiBase?: string; model?: string; dim?: number; + latencyMs?: number; + lastCheckedAt: string; error?: string; } @@ -19,8 +21,15 @@ export interface ApiTestReport { testedAt: string; } +function redactSecrets(text: string): string { + return text + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]') + .replace(/(api[_-]?key|token|secret|password)(["'\s:=]+)[^"',\s}]+/gi, '$1$2[redacted]') + .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, 'sk-[redacted]'); +} + function summarizeError(text: string): string { - return text.replace(/\s+/g, ' ').slice(0, 220); + return redactSecrets(text).replace(/\s+/g, ' ').slice(0, 220); } function publicBase(apiBase?: string): string | undefined { @@ -33,8 +42,10 @@ async function testChat( apiKey: string | undefined, model: string | undefined, ): Promise { + const startedAt = Date.now(); + const lastCheckedAt = new Date().toISOString(); const configured = Boolean(apiBase && apiKey && model); - if (!configured) return { target, ok: false, configured, apiBase: publicBase(apiBase), model, error: 'missing config' }; + if (!configured) return { target, ok: false, configured, apiBase: publicBase(apiBase), model, latencyMs: 0, lastCheckedAt, error: 'missing config' }; try { const res = await fetch(`${apiBase!.replace(/\/$/, '')}/chat/completions`, { method: 'POST', @@ -59,6 +70,8 @@ async function testChat( status: res.status, apiBase: publicBase(apiBase), model, + latencyMs: Date.now() - startedAt, + lastCheckedAt, error: res.ok ? undefined : summarizeError(text), }; } catch (err) { @@ -68,17 +81,21 @@ async function testChat( configured, apiBase: publicBase(apiBase), model, - error: err instanceof Error ? err.message : String(err), + latencyMs: Date.now() - startedAt, + lastCheckedAt, + error: summarizeError(err instanceof Error ? err.message : String(err)), }; } } async function testEmbedding(config: UserConfig): Promise { + const startedAt = Date.now(); + const lastCheckedAt = new Date().toISOString(); const apiBase = config.embeddingApiBase; const apiKey = config.embeddingApiKey; const model = config.embeddingModel; const configured = Boolean(apiBase && apiKey && model); - if (!configured) return { target: 'embedding', ok: false, configured, apiBase: publicBase(apiBase), model, error: 'missing config' }; + if (!configured) return { target: 'embedding', ok: false, configured, apiBase: publicBase(apiBase), model, latencyMs: 0, lastCheckedAt, error: 'missing config' }; try { const res = await fetch(`${apiBase!.replace(/\/$/, '')}/embeddings`, { method: 'POST', @@ -98,6 +115,8 @@ async function testEmbedding(config: UserConfig): Promise { status: res.status, apiBase: publicBase(apiBase), model, + latencyMs: Date.now() - startedAt, + lastCheckedAt, error: summarizeError(text), }; } @@ -107,7 +126,7 @@ async function testEmbedding(config: UserConfig): Promise { const vector = data.data?.[0]?.embedding; dim = Array.isArray(vector) ? vector.length : 0; } catch { - return { target: 'embedding', ok: false, configured, status: res.status, apiBase: publicBase(apiBase), model, error: 'bad JSON response' }; + return { target: 'embedding', ok: false, configured, status: res.status, apiBase: publicBase(apiBase), model, latencyMs: Date.now() - startedAt, lastCheckedAt, error: 'bad JSON response' }; } return { target: 'embedding', @@ -117,6 +136,8 @@ async function testEmbedding(config: UserConfig): Promise { apiBase: publicBase(apiBase), model, dim, + latencyMs: Date.now() - startedAt, + lastCheckedAt, error: dim > 0 ? undefined : 'embedding API returned no vector', }; } catch (err) { @@ -126,7 +147,9 @@ async function testEmbedding(config: UserConfig): Promise { configured, apiBase: publicBase(apiBase), model, - error: err instanceof Error ? err.message : String(err), + latencyMs: Date.now() - startedAt, + lastCheckedAt, + error: summarizeError(err instanceof Error ? err.message : String(err)), }; } } diff --git a/src/history/accuracy-report.ts b/src/history/accuracy-report.ts index c2694c5..d728154 100644 --- a/src/history/accuracy-report.ts +++ b/src/history/accuracy-report.ts @@ -9,6 +9,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { parseTranscript, extractUsedTools, loadRecommendationsForSession } from './tool-usage-tracker.js'; +import { sanitizePromptRecord } from '../privacy/prompts.js'; export interface AccuracyReport { sessionId: string; @@ -282,14 +283,14 @@ export function computeWeeklyStats(days = 7): WeeklyStats { }; } -export type RecommendationEntry = { sessionId: string; timestamp: string; query: string; recommended: string[]; transcriptPath?: string }; +export type RecommendationEntry = { sessionId: string; timestamp: string; query: string; queryHash?: string; recommended: string[]; transcriptPath?: string }; export function loadAllRecommendations(): RecommendationEntry[] { const REC_PATH = join(homedir(), '.lazybrain', 'recommendations.jsonl'); if (!existsSync(REC_PATH)) return []; try { const raw = readFileSync(REC_PATH, 'utf-8'); - return raw.trim().split('\n').filter(Boolean).map(l => JSON.parse(l) as RecommendationEntry); + return raw.trim().split('\n').filter(Boolean).map(l => sanitizePromptRecord(JSON.parse(l) as RecommendationEntry) as RecommendationEntry); } catch { return []; } diff --git a/src/history/history.ts b/src/history/history.ts index e3ad229..c75a4b1 100644 --- a/src/history/history.ts +++ b/src/history/history.ts @@ -8,12 +8,13 @@ import { existsSync, readFileSync, appendFileSync, mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import type { HistoryEntry } from '../types.js'; import { HISTORY_PATH } from '../constants.js'; +import { sanitizePromptRecord } from '../privacy/prompts.js'; export function loadRecentHistory(n: number): HistoryEntry[] { if (!existsSync(HISTORY_PATH)) return []; try { const lines = readFileSync(HISTORY_PATH, 'utf-8').trim().split('\n').filter(Boolean); - return lines.slice(-n).map(l => JSON.parse(l) as HistoryEntry); + return lines.slice(-n).map(l => sanitizePromptRecord(JSON.parse(l) as HistoryEntry) as HistoryEntry); } catch { return []; } @@ -23,7 +24,7 @@ export function appendHistory(entry: HistoryEntry): void { try { const dir = dirname(HISTORY_PATH); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - appendFileSync(HISTORY_PATH, JSON.stringify(entry) + '\n'); + appendFileSync(HISTORY_PATH, JSON.stringify(sanitizePromptRecord(entry)) + '\n'); } catch { // 写入倱莥䞍圱响䞻流皋 } diff --git a/src/history/tool-usage-tracker.ts b/src/history/tool-usage-tracker.ts index b27e148..60143d2 100644 --- a/src/history/tool-usage-tracker.ts +++ b/src/history/tool-usage-tracker.ts @@ -11,6 +11,7 @@ import { readFileSync, appendFileSync, existsSync } from 'node:fs'; import { LAZYBRAIN_DIR } from '../constants.js'; +import { sanitizePromptRecord } from '../privacy/prompts.js'; export const RECOMMENDATIONS_PATH = `${LAZYBRAIN_DIR}/recommendations.jsonl`; @@ -26,7 +27,9 @@ export interface ToolUseEvent { export interface RecommendationEntry { sessionId: string; timestamp: string; + /** Privacy-preserving display label, not the raw user prompt. */ query: string; + queryHash?: string; recommended: string[]; // tools recommended by hook transcriptPath?: string; } @@ -132,7 +135,7 @@ export function extractUsedTools(events: ToolUseEvent[]): string[] { * Called from hook.ts when a match produces results. */ export function writeRecommendation(entry: RecommendationEntry): void { - const line = JSON.stringify(entry); + const line = JSON.stringify(sanitizePromptRecord(entry)); appendFileSync(RECOMMENDATIONS_PATH, line + '\n'); } @@ -143,7 +146,7 @@ export function loadRecommendations(): RecommendationEntry[] { if (!existsSync(RECOMMENDATIONS_PATH)) return []; try { const raw = readFileSync(RECOMMENDATIONS_PATH, 'utf-8'); - return raw.trim().split('\n').filter(Boolean).map(l => JSON.parse(l) as RecommendationEntry); + return raw.trim().split('\n').filter(Boolean).map(l => sanitizePromptRecord(JSON.parse(l) as RecommendationEntry) as RecommendationEntry); } catch { return []; } diff --git a/src/hook/backup.ts b/src/hook/backup.ts index 1194451..27cce64 100644 --- a/src/hook/backup.ts +++ b/src/hook/backup.ts @@ -2,7 +2,7 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, import { basename, dirname, join } from 'node:path'; import type { HookInstallScope } from './types.js'; -export type HookBackupFileKey = 'settings' | 'statuslineChain' | 'installStateMap' | 'legacyInstallState'; +export type HookBackupFileKey = 'settings' | 'hooks' | 'statuslineChain' | 'installStateMap' | 'legacyInstallState'; export interface HookBackupFile { key: HookBackupFileKey; @@ -21,6 +21,7 @@ export interface HookBackupManifest { export interface CreateHookBackupOptions { scope: HookInstallScope; settingsPath: string; + hooksPath: string; statuslineChainPath: string; installStateMapPath: string; legacyInstallStatePath: string; @@ -47,6 +48,7 @@ export function createHookBackup(options: CreateHookBackupOptions): HookBackupMa const files: HookBackupFile[] = [ { key: 'settings', sourcePath: options.settingsPath, backupName: backupFileName('settings', options.settingsPath), existed: existsSync(options.settingsPath) }, + { key: 'hooks', sourcePath: options.hooksPath, backupName: backupFileName('hooks', options.hooksPath), existed: existsSync(options.hooksPath) }, { key: 'statuslineChain', sourcePath: options.statuslineChainPath, backupName: backupFileName('statuslineChain', options.statuslineChainPath), existed: existsSync(options.statuslineChainPath) }, { key: 'installStateMap', sourcePath: options.installStateMapPath, backupName: backupFileName('installStateMap', options.installStateMapPath), existed: existsSync(options.installStateMapPath) }, { key: 'legacyInstallState', sourcePath: options.legacyInstallStatePath, backupName: backupFileName('legacyInstallState', options.legacyInstallStatePath), existed: existsSync(options.legacyInstallStatePath) }, diff --git a/src/hook/doctor.ts b/src/hook/doctor.ts new file mode 100644 index 0000000..35b15de --- /dev/null +++ b/src/hook/doctor.ts @@ -0,0 +1,261 @@ +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import type { UserConfig } from '../types.js'; +import { + HOOK_INSTALL_STATE_MAP_PATH, + HOOK_INSTALL_STATE_PATH, + getClaudeConfigDir, + getStatuslineChainPath, +} from '../constants.js'; +import { createHookBackup, type HookBackupManifest } from './backup.js'; +import { + hasLazyBrainHookRegistration, + removeLazyBrainHookRegistrations, + upsertLazyBrainUserPromptSubmit, +} from './settings.js'; +import { getHookLifecycleStatus } from './status.js'; +import { clearHookBreaker, cleanHookRuntimeRecords, getHookRuntimeSnapshot, getHookRuntimeStats } from './runtime.js'; +import { readHookInstallStateForScope, writeHookInstallState } from './install-state.js'; +import type { HookInstallScope } from './types.js'; + +export interface DoctorHookConflict { + group: string; + winner: string; + suppressed: string[]; + severity: 'info' | 'warn' | 'blocker'; + reason: string; + suggestedAction?: string; +} + +export interface DoctorReport { + scope: HookInstallScope; + mode: 'diagnose' | 'diagnose+fix'; + paths: { + settings: string; + hooks: string; + }; + backup?: HookBackupManifest; + installState: { + present: boolean; + scope: string; + workspaceRoot?: string; + }; + lifecycle: { + userPromptSubmitInstalled: boolean; + userPromptSubmitCount: number; + stopClean: boolean; + }; + runtime: { + activeHooks: number; + hungHooks: number; + staleHooksCleaned: number; + breakerOpen: boolean; + avgDurationMs: number; + p95DurationMs: number; + lastSkipReason?: string; + lastError?: string; + }; + repairs: string[]; + conflicts: { + hooks: DoctorHookConflict[]; + capabilities: unknown[]; + }; +} + +function getSettingsPath(scope: HookInstallScope): string { + return scope === 'project' + ? join(resolve(process.cwd(), '.claude'), 'settings.json') + : join(getClaudeConfigDir(), 'settings.json'); +} + +function getHooksPath(scope: HookInstallScope): string { + return scope === 'project' + ? join(resolve(process.cwd(), '.claude'), 'hooks', 'hooks.json') + : join(getClaudeConfigDir(), 'hooks', 'hooks.json'); +} + +function getScopedStatuslineChainPath(scope: HookInstallScope, settingsPath: string): string { + return scope === 'project' ? join(dirname(settingsPath), 'lazybrain-statusline-chain.json') : getStatuslineChainPath(); +} + +function readJsonObject(path: string): Record { + if (!existsSync(path)) return {}; + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}; + } catch { + return {}; + } +} + +function readHooksFile(path: string): Record { + const raw = readJsonObject(path); + return (raw.hooks as Record | undefined) ?? raw; +} + +function writeHooksFile(path: string, hooks: Record): void { + mkdirSync(dirname(path), { recursive: true }); + const existing = readJsonObject(path); + existing.hooks = hooks; + if (existing.$schema === undefined) { + existing.$schema = 'https://json.schemastore.org/claude-code-settings.json'; + } + writeFileSync(path, JSON.stringify(existing, null, 2), 'utf-8'); +} + +function mergeHookMaps(...hookMaps: Array | undefined>): Record { + const merged: Record = {}; + for (const hookMap of hookMaps) { + if (!hookMap) continue; + for (const [eventName, eventHooks] of Object.entries(hookMap)) { + if (Array.isArray(eventHooks)) { + const existing = merged[eventName]; + merged[eventName] = Array.isArray(existing) + ? [...existing, ...eventHooks] + : [...eventHooks]; + } else if (eventHooks !== undefined) { + merged[eventName] = eventHooks; + } + } + } + return merged; +} + +function settingsWithMergedHooks(settings: Record, hooks: Record): Record { + return { + ...settings, + hooks: mergeHookMaps(settings.hooks as Record | undefined, hooks), + }; +} + +function hookCommand(): string { + const moduleDir = dirname(fileURLToPath(import.meta.url)); + return `node ${resolve(moduleDir, '..', '..', 'bin', 'hook.js')}`; +} + +function hookConflictDiagnostics(lifecycle: ReturnType): DoctorHookConflict[] { + const conflicts: DoctorHookConflict[] = []; + if (lifecycle.lazybrainUserPromptSubmitCount > 1) { + conflicts.push({ + group: 'hook:user-prompt-submit', + winner: 'lazybrain:user-prompt-submit', + suppressed: Array.from({ length: lifecycle.lazybrainUserPromptSubmitCount - 1 }, (_, index) => `duplicate:${index + 1}`), + severity: 'warn', + reason: 'Multiple LazyBrain UserPromptSubmit registrations are present; only one should own the event.', + suggestedAction: 'Run lazybrain doctor --fix for this scope to normalize LazyBrain-owned hook entries.', + }); + } + if (lifecycle.lazybrainStop) { + conflicts.push({ + group: 'hook:stop', + winner: 'none', + suppressed: ['lazybrain:stop'], + severity: 'warn', + reason: 'LazyBrain should not own Stop; Stop registrations are legacy and should be removed by doctor --fix.', + suggestedAction: 'Run lazybrain doctor --fix for this scope; it removes LazyBrain-owned legacy Stop entries without editing third-party hooks.', + }); + } + return conflicts; +} + +export function runHookDoctor( + scope: HookInstallScope, + shouldFix: boolean, + config: UserConfig, +): DoctorReport { + const settingsPath = getSettingsPath(scope); + const hooksPath = getHooksPath(scope); + const statuslineChainPath = getScopedStatuslineChainPath(scope, settingsPath); + let settings = readJsonObject(settingsPath); + let hooks = readHooksFile(hooksPath); + const repairs: string[] = []; + let backup: HookBackupManifest | undefined; + + if (shouldFix) { + backup = createHookBackup({ + scope, + settingsPath, + hooksPath, + statuslineChainPath, + installStateMapPath: HOOK_INSTALL_STATE_MAP_PATH, + legacyInstallStatePath: HOOK_INSTALL_STATE_PATH, + }); + + const existingState = readHookInstallStateForScope(scope, scope === 'project' ? process.cwd() : undefined); + if (existingState) { + settings = removeLazyBrainHookRegistrations(settings); + mkdirSync(dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + + const hooksSettings = upsertLazyBrainUserPromptSubmit( + removeLazyBrainHookRegistrations({ hooks } as Record), + hookCommand(), + ); + hooks = (hooksSettings.hooks ?? hooksSettings) as Record; + writeHooksFile(hooksPath, hooks); + + writeHookInstallState({ + scope: existingState.scope, + workspaceRoot: existingState.scope === 'project' + ? resolve(existingState.workspaceRoot ?? process.cwd()) + : undefined, + hookCommand: hookCommand(), + installedAt: existingState.installedAt, + statuslineMode: existingState.statuslineMode, + }); + repairs.push('normalized_hooks_json_registration'); + } else if (hasLazyBrainHookRegistration(settingsWithMergedHooks(settings, hooks))) { + repairs.push('metadata_missing_manual_reinstall_required'); + } + + const cleaned = cleanHookRuntimeRecords({ config }); + if (cleaned.staleRuns.length > 0) repairs.push(`cleaned_stale_runs:${cleaned.staleRuns.length}`); + + const runtimeBeforeReset = getHookRuntimeSnapshot({ config }); + if (runtimeBeforeReset.health.breakerUntil || runtimeBeforeReset.health.lastSkipReason === 'breaker_open') { + clearHookBreaker(); + repairs.push('cleared_breaker'); + } + } + + const installState = readHookInstallStateForScope(scope, scope === 'project' ? process.cwd() : undefined); + const runtime = getHookRuntimeSnapshot({ config }); + const runtimeStats = getHookRuntimeStats(runtime); + const lifecycle = getHookLifecycleStatus(settingsWithMergedHooks(settings, hooks), { runtime, installState }); + + return { + scope, + mode: shouldFix ? 'diagnose+fix' : 'diagnose', + paths: { + settings: settingsPath, + hooks: hooksPath, + }, + ...(backup ? { backup } : {}), + installState: { + present: Boolean(installState), + scope: installState?.scope ?? 'unknown', + ...(installState?.workspaceRoot ? { workspaceRoot: installState.workspaceRoot } : {}), + }, + lifecycle: { + userPromptSubmitInstalled: lifecycle.lazybrainUserPromptSubmit, + userPromptSubmitCount: lifecycle.lazybrainUserPromptSubmitCount, + stopClean: !lifecycle.lazybrainStop, + }, + runtime: { + activeHooks: runtime.activeRuns.length, + hungHooks: runtime.hungRuns.length, + staleHooksCleaned: runtime.staleRuns.length, + breakerOpen: runtimeStats.breakerOpen, + avgDurationMs: runtimeStats.avgDurationMs, + p95DurationMs: runtimeStats.p95DurationMs, + ...(runtime.health.lastSkipReason ? { lastSkipReason: runtime.health.lastSkipReason } : {}), + ...(runtime.health.lastError ? { lastError: runtime.health.lastError } : {}), + }, + repairs, + conflicts: { + hooks: hookConflictDiagnostics(lifecycle), + capabilities: [], + }, + }; +} diff --git a/src/hook/plan.ts b/src/hook/plan.ts index 3a8e801..7275178 100644 --- a/src/hook/plan.ts +++ b/src/hook/plan.ts @@ -164,7 +164,8 @@ export function buildHookPlan(options: HookPlanOptions): HookPlan { upstreamStatuslineCommand && !isLazyBrainStatuslineCommand(upstreamStatuslineCommand, options.statuslineScript, options.combinedStatuslineScript), ); - const alreadyCombined = Boolean(existingStatuslineCommand && existingStatuslineCommand.includes('statusline-combined.js')); + const upstreamIsLazyBrainStatusline = isLazyBrainStatuslineCommand(upstreamStatuslineCommand, options.statuslineScript, options.combinedStatuslineScript); + const alreadyCombined = Boolean(upstreamIsLazyBrainStatusline && upstreamStatuslineCommand.includes('statusline-combined.js')); let statuslineMode: StatuslinePlanMode = 'none'; let plannedStatuslineCommand = ''; @@ -174,11 +175,12 @@ export function buildHookPlan(options: HookPlanOptions): HookPlan { } else if (options.shouldInstallStatusline && hasOtherStatusline) { statuslineMode = 'combine'; plannedStatuslineCommand = options.combinedStatuslineCommand; - } else if (alreadyCombined) { + } else if (options.shouldInstallStatusline && alreadyCombined) { statuslineMode = 'combine'; plannedStatuslineCommand = options.combinedStatuslineCommand; } else if ( isLazyBrainStatuslineCommand(existingStatuslineCommand, options.statuslineScript, options.combinedStatuslineScript) || + (options.shouldInstallStatusline && upstreamIsLazyBrainStatusline) || (!upstreamStatuslineCommand && options.shouldInstallStatusline) ) { statuslineMode = 'lazybrain'; diff --git a/src/hook/readiness.ts b/src/hook/readiness.ts index ef409b5..c1310d8 100644 --- a/src/hook/readiness.ts +++ b/src/hook/readiness.ts @@ -10,6 +10,7 @@ type SettingsObject = Record; export interface ReadyScopeInput { scope: HookInstallScope; settingsPath: string; + hooksPath?: string; settings: SettingsObject; installState: HookInstallState | null; } @@ -17,9 +18,12 @@ export interface ReadyScopeInput { export interface ReadyScopeReport { scope: HookInstallScope; settingsPath: string; + hooksPath?: string; lazybrainUserPromptSubmit: boolean; lazybrainStop: boolean; lazybrainSessionStart: boolean; + lazybrainUserPromptSubmitCount: number; + duplicateLazyBrainUserPromptSubmit: boolean; installStateScope: HookInstallScope | 'missing'; } @@ -32,6 +36,7 @@ export interface ReadyReport { export interface EvaluateReadyOptions { graphExists: boolean; + compileErrors?: string[]; status?: Record | null; runtime: HookRuntimeSnapshot; scopes: ReadyScopeInput[]; @@ -41,6 +46,7 @@ export interface EvaluateReadyOptions { embeddingsBinExists: boolean; now?: number; loadAverage1m?: number; + ignoreLoadAverage?: boolean; initialBlockers?: string[]; } @@ -68,6 +74,9 @@ export function evaluateReady(options: EvaluateReadyOptions): ReadyReport { if (!options.graphExists) { blockers.push('Graph missing. Run `lazybrain scan && lazybrain compile --offline` first.'); } + if ((options.compileErrors?.length ?? 0) > 0) { + blockers.push(`Graph has ${options.compileErrors?.length} compile errors. Run \`lazybrain compile errors\` to inspect them, then rerun \`lazybrain compile --with-relations --force-relations\`.`); + } if (isRecentActiveStatus(options.status, now)) { blockers.push(`Compile state is still ${options.status?.state}. Wait for it to finish.`); @@ -78,7 +87,7 @@ export function evaluateReady(options: EvaluateReadyOptions): ReadyReport { } const loadAvgBreaker = options.config.hookSafety?.loadAvgBreaker; - if (typeof options.loadAverage1m === 'number' && typeof loadAvgBreaker === 'number' && options.loadAverage1m > loadAvgBreaker) { + if (!options.ignoreLoadAverage && typeof options.loadAverage1m === 'number' && typeof loadAvgBreaker === 'number' && options.loadAverage1m > loadAvgBreaker) { blockers.push(`Host load average is high (${options.loadAverage1m.toFixed(2)} > ${loadAvgBreaker}); LazyBrain hook would fail closed until load drops.`); } @@ -94,12 +103,18 @@ export function evaluateReady(options: EvaluateReadyOptions): ReadyReport { scopes.push({ scope: scopeInput.scope, settingsPath: scopeInput.settingsPath, + hooksPath: scopeInput.hooksPath, lazybrainUserPromptSubmit: lifecycle.lazybrainUserPromptSubmit, lazybrainStop: lifecycle.lazybrainStop, lazybrainSessionStart: lifecycle.lazybrainSessionStart, + lazybrainUserPromptSubmitCount: lifecycle.lazybrainUserPromptSubmitCount, + duplicateLazyBrainUserPromptSubmit: lifecycle.duplicateLazyBrainUserPromptSubmit, installStateScope: scopeInput.installState?.scope ?? 'missing', }); + if (lifecycle.duplicateLazyBrainUserPromptSubmit) { + blockers.push(`${scopeInput.scope} hook config contains duplicate LazyBrain UserPromptSubmit hooks (${lifecycle.lazybrainUserPromptSubmitCount}). Run \`lazybrain doctor --fix\`.`); + } if (lifecycle.lazybrainStop) { blockers.push(`${scopeInput.scope} settings still contains LazyBrain Stop hook.`); } @@ -127,6 +142,11 @@ export function evaluateReady(options: EvaluateReadyOptions): ReadyReport { const global = options.scopes.find((scope) => scope.scope === 'global'); const projectStatusline = getStatusLineCommand(project?.settings.statusLine); const globalStatusline = getStatusLineCommand(global?.settings.statusLine); + const lazybrainStatuslineVisible = isLazyBrainStatuslineCommand(projectStatusline) || isLazyBrainStatuslineCommand(globalStatusline); + const lazybrainHookInstalled = scopes.some(scope => scope.lazybrainUserPromptSubmit); + if (lazybrainHookInstalled && !lazybrainStatuslineVisible) { + warnings.push('LazyBrain hook is installed but statusline/HUD is not visible. Run `lazybrain hook install`, then restart Claude Code or cmux.'); + } if ( projectStatusline && globalStatusline && diff --git a/src/hook/settings.ts b/src/hook/settings.ts index ffc18fa..68d25cc 100644 --- a/src/hook/settings.ts +++ b/src/hook/settings.ts @@ -14,6 +14,11 @@ type SettingsObject = Record & { hooks?: Record; }; +const LEGACY_UNDERSCORED_REPO_SEGMENT = ['lazy', 'user'].join('_'); +const LAZYBRAIN_HOOK_PATH_RE = new RegExp( + String.raw`(?:^|[\s'"])(?:[^'"\s]+\/)?(?:lazybrain|lazy[-_]brain|${LEGACY_UNDERSCORED_REPO_SEGMENT})\/(?:dist\/)?bin\/hook\.js\b`, +); + function nestedHooks(entry: HookEntry): HookCommand[] { return Array.isArray(entry.hooks) ? entry.hooks : []; } @@ -21,7 +26,7 @@ function nestedHooks(entry: HookEntry): HookCommand[] { export function isLazyBrainHookCommand(command: unknown): boolean { if (typeof command !== 'string') return false; const normalized = command.replace(/\\/g, '/'); - return /lazy[-_]?brain.*\/(?:dist\/)?bin\/hook\.js\b/.test(normalized); + return LAZYBRAIN_HOOK_PATH_RE.test(normalized); } function stripLazyBrainEntries(entries: HookEntry[]): HookEntry[] { diff --git a/src/hook/status.ts b/src/hook/status.ts index cf2e33f..dcf2b0a 100644 --- a/src/hook/status.ts +++ b/src/hook/status.ts @@ -25,6 +25,10 @@ export interface HookLifecycleStatus { lazybrainUserPromptSubmit: boolean; lazybrainStop: boolean; lazybrainSessionStart: boolean; + lazybrainUserPromptSubmitCount: number; + lazybrainStopCount: number; + lazybrainSessionStartCount: number; + duplicateLazyBrainUserPromptSubmit: boolean; userPromptSubmitCommands: string[]; stopCommands: string[]; sessionStartCommands: string[]; @@ -69,6 +73,10 @@ function normalizeEntries(value: unknown): HookEntry[] { return Array.isArray(value) ? value as HookEntry[] : []; } +function countLazyBrainCommands(commands: string[]): number { + return commands.filter(isLazyBrainHookCommand).length; +} + export function getHookLifecycleStatus(settings: SettingsObject, options: HookLifecycleOptions = {}): HookLifecycleStatus { const hooks = (settings.hooks ?? {}) as Record; const userPromptSubmit = normalizeEntries(hooks.UserPromptSubmit); @@ -78,13 +86,20 @@ export function getHookLifecycleStatus(settings: SettingsObject, options: HookLi const userPromptSubmitCommands = flattenCommands(userPromptSubmit); const stopCommands = flattenCommands(stop); const sessionStartCommands = flattenCommands(sessionStart); + const lazybrainUserPromptSubmitCount = countLazyBrainCommands(userPromptSubmitCommands); + const lazybrainStopCount = countLazyBrainCommands(stopCommands); + const lazybrainSessionStartCount = countLazyBrainCommands(sessionStartCommands); const runtime = options.runtime ?? getHookRuntimeSnapshot(); const runtimeStats = getHookRuntimeStats(runtime, options.now); return { - lazybrainUserPromptSubmit: userPromptSubmitCommands.some(isLazyBrainHookCommand), - lazybrainStop: stopCommands.some(isLazyBrainHookCommand), - lazybrainSessionStart: sessionStartCommands.some(isLazyBrainHookCommand), + lazybrainUserPromptSubmit: lazybrainUserPromptSubmitCount > 0, + lazybrainStop: lazybrainStopCount > 0, + lazybrainSessionStart: lazybrainSessionStartCount > 0, + lazybrainUserPromptSubmitCount, + lazybrainStopCount, + lazybrainSessionStartCount, + duplicateLazyBrainUserPromptSubmit: lazybrainUserPromptSubmitCount > 1, userPromptSubmitCommands, stopCommands, sessionStartCommands, diff --git a/src/index.ts b/src/index.ts index 300057a..44ae6bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,16 @@ export type { CapabilityGraph, CapabilityKind, CapabilityMeta, + CapabilitySideEffect, + ChoiceCost, + ChoiceLatency, + ChoiceOption, + ChoiceOptionKind, + ChoiceRisk, + ChoiceSet, Confidence, + ConflictNotice, + DecisionPolicy, HistoryEntry, LLMProvider, LLMProviderConfig, diff --git a/src/integrations/gitnexus.ts b/src/integrations/gitnexus.ts new file mode 100644 index 0000000..21850b4 --- /dev/null +++ b/src/integrations/gitnexus.ts @@ -0,0 +1,139 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; + +export interface GitNexusContext { + metaPath: string; + repoPath: string; + repoName: string; + indexedAt?: string; + lastCommit?: string; + stats?: { + files?: number; + nodes?: number; + edges?: number; + communities?: number; + processes?: number; + embeddings?: number; + }; +} + +export interface GitNexusStatus extends GitNexusContext { + available: boolean; + source: 'local-meta'; + mcpRequired: false; + state: 'missing' | 'current' | 'stale' | 'invalid' | 'unknown'; + currentCommit?: string; + stale: boolean; + contextUri?: string; + artifactWarnings: string[]; +} + +export function findGitNexusContext(startDir = process.cwd()): GitNexusContext | undefined { + let dir = startDir; + for (let i = 0; i < 6; i++) { + const metaPath = join(dir, '.gitnexus', 'meta.json'); + if (existsSync(metaPath)) { + try { + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as { + repoPath?: string; + indexedAt?: string; + lastCommit?: string; + stats?: GitNexusContext['stats']; + }; + const repoPath = meta.repoPath ?? dir; + return { + metaPath, + repoPath, + repoName: basename(repoPath), + indexedAt: meta.indexedAt, + lastCommit: meta.lastCommit, + stats: meta.stats, + }; + } catch { + return { metaPath, repoPath: dir, repoName: basename(dir) }; + } + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return undefined; +} + +function readCurrentCommit(cwd: string): string | undefined { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { + cwd, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return undefined; + } +} + +function gitNexusArtifactWarnings(repoPath: string): string[] { + let entries: string[]; + try { + entries = readdirSync(repoPath) + .filter(name => name.startsWith('.gitnexus.') || name === '.gitnexus.wal.backup') + .sort(); + } catch { + return []; + } + if (entries.length === 0) return []; + const shown = entries.slice(0, 8).map(name => `GitNexus artifact present: ${name}`); + if (entries.length > shown.length) { + shown.push(`GitNexus artifact present: ${entries.length - shown.length} more`); + } + return shown; +} + +export function getGitNexusStatus(startDir = process.cwd()): GitNexusStatus { + const context = findGitNexusContext(startDir); + const repoPath = context?.repoPath ?? startDir; + const currentCommit = readCurrentCommit(repoPath); + const stale = Boolean(context?.lastCommit && currentCommit && context.lastCommit !== currentCommit); + const invalid = Boolean(context && !context.indexedAt && !context.lastCommit && !context.stats); + const state = !context + ? 'missing' + : invalid + ? 'invalid' + : stale + ? 'stale' + : context.lastCommit && currentCommit + ? 'current' + : 'unknown'; + return { + metaPath: context?.metaPath ?? join(repoPath, '.gitnexus', 'meta.json'), + repoPath, + repoName: context?.repoName ?? basename(repoPath), + indexedAt: context?.indexedAt, + lastCommit: context?.lastCommit, + stats: context?.stats, + available: Boolean(context), + source: 'local-meta', + mcpRequired: false, + state, + currentCommit, + stale, + contextUri: context ? `gitnexus://repo/${context.repoName}/context` : undefined, + artifactWarnings: gitNexusArtifactWarnings(repoPath), + }; +} + +export function gitNexusSkillNamesForRoute(query: string, comboId?: string, categories: string[] = []): string[] { + const q = query.toLowerCase(); + const categoryText = categories.join(' ').toLowerCase(); + const wantsReview = comboId === 'code_review_regression' || /\b(pr|pull request|review|regression)\b|审查|审栞|回園|风险/.test(q); + const wantsImpact = /\b(impact|blast radius|depends|dependency|break|risk)\b|圱响|䟝赖|䌚坏|风险/.test(q); + const wantsDebug = comboId === 'debug_crash' || comboId === 'debug_stuck_runtime' || /\b(debug|bug|crash|error|failing|broken)\b|调试|排查|报错|厩溃|倱莥/.test(q); + const wantsRefactor = comboId === 'refactor_clean' || /\b(refactor|cleanup|rename|extract|split)\b|重构|枅理|改名|拆分/.test(q); + const names: string[] = []; + if (wantsReview || categoryText.includes('code-quality')) names.push('gitnexus-pr-review'); + if (wantsImpact) names.push('gitnexus-impact-analysis'); + if (wantsDebug) names.push('gitnexus-debugging'); + if (wantsRefactor) names.push('gitnexus-refactoring'); + return [...new Set(names)]; +} diff --git a/src/matcher/embedding-layer.ts b/src/matcher/embedding-layer.ts index 9e058f6..d84bdd3 100644 --- a/src/matcher/embedding-layer.ts +++ b/src/matcher/embedding-layer.ts @@ -86,12 +86,16 @@ export async function semanticMatch( const activeNodeIds = new Set(nodes.map((n) => n.id)); const covered = ids.filter((id) => activeNodeIds.has(id)).length; - if (covered / Math.max(1, activeNodeIds.size) < 0.8) { + const coverage = covered / Math.max(1, activeNodeIds.size); + if (covered === 0) { return { results: [], - warnings: [`Semantic engine requested but embedding cache is stale (${covered}/${activeNodeIds.size} active nodes covered).`], + warnings: [`Semantic engine requested but embedding cache has no active coverage (${covered}/${activeNodeIds.size} active nodes covered).`], }; } + if (coverage < 0.8) { + warnings.push(`Semantic engine using partial embedding cache (${covered}/${activeNodeIds.size} active nodes covered); tag routing remains primary for missing nodes.`); + } const bin = readFileSync(EMBEDDINGS_BIN_PATH); const dim = bin.byteLength / Float32Array.BYTES_PER_ELEMENT / ids.length; @@ -121,7 +125,8 @@ export async function semanticMatch( if (!cap || cap.status === 'disabled' || !platformCompatible(cap, platform)) continue; const cosine = dotProduct(queryEmbedding, matrix, i * dim, dim); if (cosine < 0.25) continue; - const score = Math.max(0, Math.min(1, (cosine + 1) / 2)); + const baseScore = Math.max(0, Math.min(1, (cosine + 1) / 2)); + const score = coverage < 0.8 ? baseScore * 0.85 : baseScore; results.push({ capability: cap, score, diff --git a/src/matcher/tag-layer.ts b/src/matcher/tag-layer.ts index 1c29eb8..9ce23d7 100644 --- a/src/matcher/tag-layer.ts +++ b/src/matcher/tag-layer.ts @@ -85,7 +85,9 @@ const LANG_KEYWORDS = new Set([ /** Penalty multiplier for language-specialized capabilities on generic queries */ const LANG_SPECIALTY_PENALTY = 0.5; +const SCOPED_MAINTENANCE_PENALTY = 0.25; const INTENT_CLUSTER_BOOST = 0.35; +const SPECIALIZED_INTENT_BOOST = 0.35; interface IntentCluster { triggers: string[]; @@ -195,13 +197,156 @@ const INTENT_CLUSTERS: IntentCluster[] = [ }, { triggers: ['spring', 'springboot', 'java', 'project-session-manager'], - nameHints: ['spring', 'debugger', 'project-session'], + nameHints: ['springboot', 'spring'], tagHints: ['spring', 'java', 'backend'], descHints: ['spring', 'java', 'backend'], categoryHints: ['development', 'deployment'], }, ]; +interface SpecializedIntentRule { + pattern: RegExp; + nameHints?: string[]; + tagHints?: string[]; + descHints?: string[]; + boost?: number; +} + +const SPECIALIZED_INTENT_RULES: SpecializedIntentRule[] = [ + { + pattern: /(fix.*failing.*tests?|failing.*tests?.*(pr|pull request)|test failure.*(pr|pull request)|ä¿®.*测试|测试倱莥.*(pr|pull request|匀|提亀)|倱莥测试.*(pr|pull request|匀|提亀))/i, + nameHints: ['ai-regression-testing', 'build-fix', 'github-ops', 'project-session-manager', 'minimal change'], + descHints: ['failing test', 'failed test', 'pull request', 'pr handoff', 'ci failure', 'minimal'], + boost: 0.55, + }, + { + pattern: /(product direction|product strategy|replan.*product|产品方向|產品方向|重新规划.*产品|重新芏劃.*產品|规划.*执行方案|芏劃.*執行方案)/i, + nameHints: ['office-hours', 'plan-ceo-review', 'product-capability', 'ce:plan'], + tagHints: ['product', 'planning', 'strategy', 'startup'], + descHints: ['product', 'startup', 'brainstorm', 'worth building', 'design doc'], + boost: 0.55, + }, + { + pattern: /(ai.*slop|slop|ai-generated|ai generated|垃土代码|垃土代碌)/i, + nameHints: ['ai-slop-cleaner'], + tagHints: ['ai-generated-code', 'slop'], + descHints: ['ai-generated', 'low-quality'], + boost: 0.42, + }, + { + pattern: /(database.*(migration|migrate)|migrat.*database|数据库.*迁移|資料庫.*遷移|迁移.*数据库|遷移.*資料庫)/i, + nameHints: ['database optimizer', 'backend-patterns', 'postgres-patterns'], + descHints: ['database schemas', 'schema design', 'database optimization'], + boost: 0.95, + }, + { + pattern: /(database.*(query|queries|optimi[sz])|optimi[sz].*database|数据库.*(查询|䌘化)|資料庫.*(查詢|優化)|䌘化.*数据库|優化.*資料庫)/i, + nameHints: ['database optimizer', 'postgres-patterns', 'prompt-optimize'], + descHints: ['query optimization', 'database optimization', 'performance tuning'], + boost: 0.62, + }, + { + pattern: /(codebase.*(onboarding|tour|guide)|onboarding|new developer|新人䞊手|代码库新人|代碌庫新人|入闚|入門)/i, + nameHints: ['code-tour', 'claude-code-bridge', 'skill-create', 'code-review'], + tagHints: ['onboarding', 'code-tour', 'codebase', 'tour'], + descHints: ['onboarding', 'codebase', 'guide'], + boost: 0.45, + }, + { + pattern: /(project planning|project plan|plan project|项目规划|項目芏劃|规划项目|芏劃項目)/i, + nameHints: ['omc-plan', 'planner', 'product-capability'], + tagHints: ['planning', 'plan', 'product'], + descHints: ['implementation planning', 'product discussions'], + boost: 0.45, + }, + { + pattern: /(code review|review code|审查.*代码|代码.*审查|審查.*代碌|代碌.*審查)/i, + nameHints: ['code reviewer', 'code-reviewer', 'code-review', 'coding-standards'], + tagHints: ['code-review', 'review', 'code-quality'], + descHints: ['code review', 'review code'], + boost: 0.12, + }, + { + pattern: /(pr review|review.*pr|审查.*pr|審查.*pr|pr.*审查|pr.*審查)/i, + nameHints: ['gitnexus-pr-review', 'review-pr', 'code-review', 'code reviewer'], + descHints: ['pull request', 'code changes'], + boost: 0.22, + }, + { + pattern: /(system architecture|architecture design|系统架构|系統架構|架构讟计|架構蚭蚈|讟计系统架构)/i, + nameHints: ['architect', 'software architect', 'backend architect'], + descHints: ['architecture', 'system design'], + boost: 0.8, + }, + { + pattern: /(api documentation|api docs|generate api documentation|生成.*api.*文档|api.*文档|api.*文件)/i, + nameHints: ['api-design', 'technical writer', 'writer'], + descHints: ['api docs', 'documentation', 'technical writer'], + boost: 0.35, + }, + { + pattern: /(deploy to production|production deploy|ship to production)/i, + nameHints: ['frontend-design', 'product-capability', 'ai engineer'], + boost: 0.75, + }, + { + pattern: /(郚眲到生产|郚眲到生產|发垃到生产|癌䜈到生產|䞊线生产|䞊線生產)/i, + nameHints: ['setup', 'verification-loop', 'verify'], + descHints: ['verify', 'verification'], + boost: 0.75, + }, + { + pattern: /(performance optimization|optimize performance|性胜䌘化|性胜優化)/i, + nameHints: ['prompt-optimize', 'database optimizer', 'backend-patterns'], + descHints: ['database optimization', 'performance tuning', 'backend architecture'], + boost: 0.75, + }, + { + pattern: /(重构.*后端|重構.*埌端|后端.*重构|埌端.*重構|refactor.*backend|backend.*refactor)/i, + nameHints: ['backend-patterns', 'backend architect', 'refactor-clean'], + descHints: ['backend architecture', 'backend patterns', 'refactor'], + boost: 0.75, + }, + { + pattern: /(写 python 代码|寫 python 代碌|python.*(code|dev|development|匀发|開癌)|python 匀发|python 開癌)/i, + nameHints: ['python-review', 'python-patterns', 'code-review'], + descHints: ['python code', 'pythonic', 'python'], + boost: 0.35, + }, + { + pattern: /(rust.*(dev|development|匀发|開癌)|rust 匀发|rust 開癌)/i, + nameHints: ['rust-review', 'rust-patterns', 'rust-build'], + boost: 0.45, + }, + { + pattern: /(frontend ui component|frontend component|ui component)/i, + nameHints: ['frontend-design', 'designer', 'frontend-slides'], + descHints: ['frontend components', 'web components', 'visual design'], + boost: 0.35, + }, + { + pattern: /(write.*unit tests?|add.*unit tests?|unit tests?|写.*单元测试|寫.*單元枬詊|写.*单测|寫.*單枬|单元测试|單元枬詊|单测|單枬)/i, + nameHints: ['test-coverage', 'tdd', 'test-engineer', 'tdd-workflow'], + tagHints: ['coverage', 'tdd', 'test'], + descHints: ['test coverage', 'missing tests', 'test-driven', 'test strategy'], + boost: 0.68, + }, + { + pattern: /(debug.*(bug|issue|crash|error)|bug.*(debug|crash|error)|crash.*debug|调试.*bug|調詊.*bug|排查.*bug|bug.*排查|厩溃.*调试|厩朰.*調詊|报错.*排查|報錯.*排查)/i, + nameHints: ['debugger', 'agent-introspection-debugging', 'build-fix'], + tagHints: ['debugger', 'debugging', 'debug', 'build'], + descHints: ['root-cause', 'stack trace', 'regression isolation', 'compilation error'], + boost: 0.72, + }, + { + pattern: /(adversarial.*dual.*review|dual.*review|对抗性.*双.*审查|對抗性.*雙.*審查|双暡型.*审查|雙暡型.*審查)/i, + nameHints: ['santa-loop', 'code-reviewer', 'critic'], + tagHints: ['dual-review', 'review', 'critic'], + descHints: ['adversarial dual-review', 'independent model reviewers'], + boost: 0.7, + }, +]; + /** * Check if a capability is language/framework-specialized. * Returns the matching language keyword, or undefined if generic. @@ -231,6 +376,20 @@ function queryHasLangHint(tokens: string[]): boolean { return false; } +function isScopedMaintenanceCapability(cap: Capability): boolean { + const haystack = [ + cap.name, + cap.description, + ...cap.tags, + ...cap.exampleQueries, + ].join(' ').toLowerCase(); + return /claude[.-]?md|claude\.md|project memory/.test(haystack); +} + +function queryHasScopedMaintenanceSignal(query: string): boolean { + return /claude[.-]?md|claude\.md|project memory|项目记忆|專案蚘憶|项目规则|專案芏則/i.test(query); +} + function matchesAnyHint(target: string, hints: string[] | undefined): boolean { if (!hints || hints.length === 0) return false; return hints.some(hint => target.includes(hint)); @@ -266,6 +425,26 @@ function computeIntentClusterBoost(tokens: string[], cap: Capability): number { return boost; } +function computeSpecializedIntentBoost(query: string, cap: Capability): number { + const normalized = normalizeQuery(query).toLowerCase(); + const nameLower = cap.name.toLowerCase(); + const tagLowers = cap.tags.map(t => t.toLowerCase()); + const descLower = cap.description.toLowerCase(); + + let boost = 0; + for (const rule of SPECIALIZED_INTENT_RULES) { + if (!rule.pattern.test(normalized)) continue; + const matchesRule = + matchesAnyHint(nameLower, rule.nameHints) || + tagLowers.some(tag => matchesAnyHint(tag, rule.tagHints)) || + matchesAnyHint(descLower, rule.descHints); + if (matchesRule) { + boost = Math.max(boost, rule.boost ?? SPECIALIZED_INTENT_BOOST); + } + } + return boost; +} + /** * Check if a token matches a target string. */ @@ -280,6 +459,11 @@ function tokenMatches(token: string, target: string): boolean { return before && after; } +function nameTokenCoverage(cap: Capability, tokens: string[]): number { + const nameLower = cap.name.toLowerCase(); + return tokens.filter(token => tokenMatches(token, nameLower)).length; +} + /** * Score how well a capability matches the query tokens. * Original tokens score at full weight; bridge-expanded tokens at reduced weight. @@ -477,17 +661,24 @@ export function tagMatch( // Score all capabilities const hasLangHint = queryHasLangHint([...original, ...expanded]); + const allTokens = [...original, ...expanded]; const scored: MatchResult[] = []; for (const cap of filtered) { let score = scoreCapability(original, expanded, cap, query); - score += computeIntentClusterBoost([...original, ...expanded], cap); + score += computeIntentClusterBoost(allTokens, cap); + const specializedBoost = computeSpecializedIntentBoost(query, cap); + score += specializedBoost; if (score < MIN_MATCH_SCORE) continue; // Penalize language-specialized capabilities on generic queries - if (!hasLangHint && getLangSpecialty(cap)) { + if (!hasLangHint && specializedBoost === 0 && getLangSpecialty(cap)) { score *= LANG_SPECIALTY_PENALTY; } + if (isScopedMaintenanceCapability(cap) && !queryHasScopedMaintenanceSignal(query)) { + score *= SCOPED_MAINTENANCE_PENALTY; + } + score = Math.min(1, score); if (score >= MIN_MATCH_SCORE) { @@ -501,6 +692,14 @@ export function tagMatch( } // Sort by score descending - scored.sort((a, b) => b.score - a.score); + scored.sort((a, b) => { + const scoreDelta = b.score - a.score; + if (Math.abs(scoreDelta) > 0.001) return scoreDelta; + const intentDelta = computeSpecializedIntentBoost(query, b.capability) - computeSpecializedIntentBoost(query, a.capability); + if (intentDelta !== 0) return intentDelta; + const nameDelta = nameTokenCoverage(b.capability, allTokens) - nameTokenCoverage(a.capability, allTokens); + if (nameDelta !== 0) return nameDelta; + return a.capability.name.localeCompare(b.capability.name); + }); return scored.slice(0, maxResults); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0baa59a..28499d6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,10 +1,16 @@ -import type { Capability, RouteTarget, UserConfig } from '../types.js'; +import type { Capability, ChoiceSet, RouteSpec, RouteTarget, UserConfig } from '../types.js'; import type { Graph } from '../graph/graph.js'; import { buildRouteSpec, isRouteTarget } from '../orchestrator/route.js'; import { listCombos } from '../combos/registry.js'; import { loadRecentHistory } from '../history/history.js'; import { loadProfile } from '../history/profile.js'; import { getPackageVersion } from '../version.js'; +import { + listMcpToolDefinitions, + listMcpToolNames, + MAX_MCP_LIMIT, + MAX_MCP_QUERY_LENGTH, +} from './tools.js'; type JsonRpcRequest = { jsonrpc?: string; @@ -18,11 +24,22 @@ type McpContext = { config: UserConfig; }; -const TOOL_DESCRIPTION_ROUTE = - 'Call lazybrain.route before non-trivial coding, review, debugging, UI, docs, release, hook, testing, or multi-agent tasks. Call it when the request is vague or when routing skills/agents can reduce context. Do not call it for simple factual answers or tiny edits.'; +type ToolStatus = 'success' | 'warning' | 'error'; -const MAX_QUERY_LENGTH = 2000; -const MAX_LIMIT = 20; +interface ToolObservation { + status: ToolStatus; + summary: string; + next_actions: string[]; + artifacts: string[]; + choices?: ChoiceSet; + data?: T; + error?: { + message: string; + root_cause_hint: string; + safe_retry: string; + stop_condition: string; + }; +} function errorResponse(id: JsonRpcRequest['id'], code: number, message: string) { return { jsonrpc: '2.0', id: id ?? null, error: { code, message } }; @@ -36,14 +53,53 @@ function paramsObject(params: unknown): Record { return params && typeof params === 'object' ? params as Record : {}; } -function toolText(data: unknown) { +function toolText(data: unknown, isError = false) { return { + ...(isError ? { isError: true } : {}), content: [ { type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }, ], }; } +function successObservation( + summary: string, + data: T, + nextActions: string[], + artifacts: string[] = [], + choices?: ChoiceSet, +): ToolObservation { + return { + status: 'success', + summary, + next_actions: nextActions, + artifacts, + ...(choices ? { choices } : {}), + data, + }; +} + +function errorObservation( + summary: string, + message: string, + rootCauseHint: string, + safeRetry: string, + stopCondition: string, +): ToolObservation { + return { + status: 'error', + summary, + next_actions: [safeRetry, stopCondition], + artifacts: [], + error: { + message, + root_cause_hint: rootCauseHint, + safe_retry: safeRetry, + stop_condition: stopCondition, + }, + }; +} + function sanitizeCapability(cap: Capability): Record { return { id: cap.id, @@ -79,52 +135,52 @@ function searchCapabilities(graph: Graph, query: string, limit: number): Record< .map(sanitizeCapability); } +function routeNextActions(spec: RouteSpec): string[] { + if (spec.mode === 'no_route_needed') { + return ['Handle directly; do not load skill bodies unless the task grows.']; + } + if (spec.mode === 'needs_clarification') { + return ['Ask the clarification questions before selecting tools.', 'Call lazybrain.route again after the user clarifies.']; + } + return [ + spec.entryCommand ? `Use entry command: ${spec.entryCommand}` : `Use adapters.${spec.target}.prompt as the execution prompt.`, + 'Run the listed verification before marking the task done.', + ]; +} + +function routeArtifacts(spec: RouteSpec): string[] { + return [ + `route:${spec.mode}`, + `target:${spec.target}`, + ...(spec.combo ? [`combo:${spec.combo}`] : []), + ...spec.skills.slice(0, 5).map((skill) => `capability:${skill.id}`), + ]; +} + +function invalidQueryObservation(toolName: string, value: unknown): ReturnType | null { + if (typeof value !== 'string' || !value.trim()) { + return toolText(errorObservation( + `${toolName} could not run: missing query`, + 'Missing required argument: query', + 'The tool requires a non-empty query string.', + 'Retry with {"query":""} and keep it under the documented length limit.', + 'Stop retrying if there is no concrete user task to route or search.', + ), true); + } + if (value.length > MAX_MCP_QUERY_LENGTH) { + return toolText(errorObservation( + `${toolName} could not run: query too long`, + `Query is too long. Limit: ${MAX_MCP_QUERY_LENGTH} characters.`, + 'The request exceeds the MCP tool input budget.', + 'Retry with a shorter task summary and put large context in file references.', + 'Stop retrying if reducing the query would remove the task objective.', + ), true); + } + return null; +} + function toolsList() { - return { - tools: [ - { - name: 'lazybrain.route', - description: TOOL_DESCRIPTION_ROUTE, - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', maxLength: MAX_QUERY_LENGTH }, - target: { type: 'string', enum: ['generic', 'claude', 'codex', 'cursor'] }, - }, - required: ['query'], - }, - }, - { - name: 'lazybrain.search', - description: 'Search the LazyBrain capability database without loading full skill bodies.', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', maxLength: MAX_QUERY_LENGTH }, - limit: { type: 'number', minimum: 1, maximum: MAX_LIMIT }, - }, - required: ['query'], - }, - }, - { - name: 'lazybrain.skill_card', - description: 'Return compact public metadata for one skill or capability. Does not return the full skill body.', - inputSchema: { - type: 'object', - properties: { name: { type: 'string', maxLength: 200 } }, - required: ['name'], - }, - }, - { - name: 'lazybrain.combos', - description: 'List built-in advisory route combo templates by optional category.', - inputSchema: { - type: 'object', - properties: { category: { type: 'string', maxLength: 100 } }, - }, - }, - ], - }; + return { tools: listMcpToolDefinitions() }; } async function callTool(name: string, args: Record, ctx: McpContext): Promise { @@ -132,37 +188,86 @@ async function callTool(name: string, args: Record, ctx: McpCon case 'lazybrain.route': { const query = args.query; const target = typeof args.target === 'string' && isRouteTarget(args.target) ? args.target as RouteTarget : 'generic'; - if (typeof query !== 'string' || !query.trim()) throw new Error('Missing required argument: query'); - if (query.length > MAX_QUERY_LENGTH) throw new Error(`Query is too long. Limit: ${MAX_QUERY_LENGTH} characters.`); - const spec = await buildRouteSpec(query, { + const invalid = invalidQueryObservation('lazybrain.route', query); + if (invalid) return invalid; + const queryText = (query as string).trim(); + const spec = await buildRouteSpec(queryText, { graph: ctx.graph, config: ctx.config, history: loadRecentHistory(50), profile: loadProfile() ?? undefined, target, }); - return toolText(spec); + return toolText(successObservation( + `RouteSpec ${spec.mode} for target ${spec.target}`, + spec, + routeNextActions(spec), + routeArtifacts(spec), + spec.choices, + )); } case 'lazybrain.search': { const query = args.query; - const limit = Math.min(MAX_LIMIT, Math.max(1, Number(args.limit ?? 8))); - if (typeof query !== 'string' || !query.trim()) throw new Error('Missing required argument: query'); - if (query.length > MAX_QUERY_LENGTH) throw new Error(`Query is too long. Limit: ${MAX_QUERY_LENGTH} characters.`); - return toolText({ results: searchCapabilities(ctx.graph, query, Number.isFinite(limit) ? limit : 8) }); + const limit = Math.min(MAX_MCP_LIMIT, Math.max(1, Number(args.limit ?? 8))); + const invalid = invalidQueryObservation('lazybrain.search', query); + if (invalid) return invalid; + const queryText = (query as string).trim(); + const results = searchCapabilities(ctx.graph, queryText, Number.isFinite(limit) ? limit : 8); + return toolText(successObservation( + `Found ${results.length} capabilities for "${queryText}"`, + { results }, + results.length > 0 + ? ['Call lazybrain.skill_card for compact metadata on a selected capability.', 'Call lazybrain.route with the full task before execution.'] + : ['Retry with broader task words or a different category.', 'Stop retrying if the capability graph is empty and run lazybrain scan first.'], + results.map((result) => `capability:${String(result.id)}`), + )); } case 'lazybrain.skill_card': { const nameArg = args.name; - if (typeof nameArg !== 'string' || !nameArg.trim()) throw new Error('Missing required argument: name'); + if (typeof nameArg !== 'string' || !nameArg.trim()) { + return toolText(errorObservation( + 'lazybrain.skill_card could not run: missing name', + 'Missing required argument: name', + 'The tool requires a skill or capability name.', + 'Retry with {"name":""} from lazybrain.search or lazybrain.route.', + 'Stop retrying if no candidate capability is available.', + ), true); + } const cap = findCapability(ctx.graph, nameArg.trim()); - if (!cap) throw new Error(`Capability not found: ${nameArg}`); - return toolText({ capability: sanitizeCapability(cap) }); + if (!cap) { + return toolText(errorObservation( + 'lazybrain.skill_card could not find that capability', + `Capability not found: ${nameArg}`, + 'The requested name does not match a capability id, exact name, or name substring.', + 'Retry with lazybrain.search to discover the canonical capability name.', + 'Stop retrying if search returns no relevant capability.', + ), true); + } + return toolText(successObservation( + `Capability card for ${cap.name}`, + { capability: sanitizeCapability(cap) }, + ['Use this metadata to decide whether the capability fits.', 'Call lazybrain.route for workflow, guardrails, and verification before execution.'], + [`capability:${cap.id}`], + )); } case 'lazybrain.combos': { const category = typeof args.category === 'string' ? args.category : undefined; - return toolText({ combos: listCombos(category) }); + const combos = listCombos(category); + return toolText(successObservation( + `Found ${combos.length} route combos${category ? ` in ${category}` : ''}`, + { combos }, + ['Call lazybrain.route with a real task to select and adapt a combo.', 'Use combo entryCommand only after confirming the target agent.'], + combos.map((combo) => `combo:${combo.id}`), + )); } default: - throw new Error(`Unknown tool: ${name}`); + return toolText(errorObservation( + 'Unknown LazyBrain MCP tool', + `Unknown tool: ${name}`, + 'The MCP client requested a tool name that is not in tools/list.', + 'Retry with one of lazybrain.route, lazybrain.search, lazybrain.skill_card, or lazybrain.combos.', + 'Stop retrying if tools/list does not include the desired tool.', + ), true); } } @@ -194,13 +299,22 @@ export async function handleMcpRequest(request: JsonRpcRequest, ctx: McpContext) } } -function writeFramed(message: unknown): void { +type McpWireMessage = { + body: string; + framed: boolean; +}; + +export function formatMcpWireResponse(message: unknown, framed: boolean): string { const payload = JSON.stringify(message); - process.stdout.write(`Content-Length: ${Buffer.byteLength(payload)}\r\n\r\n${payload}`); + return framed ? `Content-Length: ${Buffer.byteLength(payload)}\r\n\r\n${payload}` : `${payload}\n`; +} + +function writeMessage(message: unknown, framed: boolean): void { + process.stdout.write(formatMcpWireResponse(message, framed)); } -function extractMessages(buffer: string): { messages: string[]; rest: string } { - const messages: string[] = []; +function extractMessages(buffer: string): { messages: McpWireMessage[]; rest: string } { + const messages: McpWireMessage[] = []; let rest = buffer; while (rest.length > 0) { @@ -217,7 +331,7 @@ function extractMessages(buffer: string): { messages: string[]; rest: string } { const length = Number(match[1]); const bodyStart = headerEnd + 4; if (rest.length < bodyStart + length) break; - messages.push(rest.slice(bodyStart, bodyStart + length)); + messages.push({ body: rest.slice(bodyStart, bodyStart + length), framed: true }); rest = rest.slice(bodyStart + length); continue; } @@ -226,7 +340,7 @@ function extractMessages(buffer: string): { messages: string[]; rest: string } { if (newline === -1) break; const line = rest.slice(0, newline).trim(); rest = rest.slice(newline + 1); - if (line) messages.push(line); + if (line) messages.push({ body: line, framed: false }); } return { messages, rest }; @@ -241,15 +355,15 @@ export function runMcpStdioServer(ctx: McpContext): void { buffer = parsed.rest; for (const message of parsed.messages) { try { - const response = await handleMcpRequest(JSON.parse(message) as JsonRpcRequest, ctx); - if (response) writeFramed(response); + const response = await handleMcpRequest(JSON.parse(message.body) as JsonRpcRequest, ctx); + if (response) writeMessage(response, message.framed); } catch { - writeFramed(errorResponse(null, -32700, 'Parse error')); + writeMessage(errorResponse(null, -32700, 'Parse error'), message.framed); } } }); } export function getMcpToolNames(): string[] { - return toolsList().tools.map((tool) => tool.name); + return listMcpToolNames(); } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts new file mode 100644 index 0000000..0dbe7d7 --- /dev/null +++ b/src/mcp/tools.ts @@ -0,0 +1,77 @@ +const TOOL_DESCRIPTION_ROUTE = + 'Call lazybrain.route before non-trivial coding, review, debugging, UI, docs, release, hook, testing, or multi-agent tasks. Call it when the request is vague or when routing skills/agents can reduce context. Do not call it for simple factual answers or tiny edits.'; + +export const MAX_MCP_QUERY_LENGTH = 2000; +export const MAX_MCP_LIMIT = 20; + +export type McpInputSchema = { + readonly type: 'object'; + readonly properties: Record; + readonly required?: readonly string[]; +}; + +export interface McpToolDefinition { + readonly name: string; + readonly description: string; + readonly inputSchema: McpInputSchema; +} + +export const MCP_TOOL_DEFINITIONS = [ + { + name: 'lazybrain.route', + description: TOOL_DESCRIPTION_ROUTE, + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', maxLength: MAX_MCP_QUERY_LENGTH }, + target: { type: 'string', enum: ['generic', 'claude', 'codex', 'cursor'] }, + }, + required: ['query'], + }, + }, + { + name: 'lazybrain.search', + description: 'Search the LazyBrain capability database without loading full skill bodies.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', maxLength: MAX_MCP_QUERY_LENGTH }, + limit: { type: 'number', minimum: 1, maximum: MAX_MCP_LIMIT }, + }, + required: ['query'], + }, + }, + { + name: 'lazybrain.skill_card', + description: 'Return compact public metadata for one skill or capability. Does not return the full skill body.', + inputSchema: { + type: 'object', + properties: { name: { type: 'string', maxLength: 200 } }, + required: ['name'], + }, + }, + { + name: 'lazybrain.combos', + description: 'List built-in advisory route combo templates by optional category.', + inputSchema: { + type: 'object', + properties: { category: { type: 'string', maxLength: 100 } }, + }, + }, +] as const satisfies readonly McpToolDefinition[]; + +export function listMcpToolDefinitions(): McpToolDefinition[] { + return MCP_TOOL_DEFINITIONS.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: { + type: tool.inputSchema.type, + properties: { ...tool.inputSchema.properties }, + ...('required' in tool.inputSchema ? { required: [...tool.inputSchema.required] } : {}), + }, + })); +} + +export function listMcpToolNames(): string[] { + return MCP_TOOL_DEFINITIONS.map(tool => tool.name); +} diff --git a/src/orchestrator/route-dogfood-cases.ts b/src/orchestrator/route-dogfood-cases.ts new file mode 100644 index 0000000..fa316fb --- /dev/null +++ b/src/orchestrator/route-dogfood-cases.ts @@ -0,0 +1,44 @@ +export const DOGFOOD_ROUTE_CASES = [ + { query: 'fix failing tests and create a PR', combo: 'test_pr_repair', category: 'pr' }, + { query: '垮我修倱莥测试并提亀 PR', combo: 'test_pr_repair', category: 'pr' }, + { query: '修测试然后匀 PR', combo: 'test_pr_repair', category: 'pr' }, + { query: 'failed tests pull request handoff', combo: 'test_pr_repair', category: 'pr' }, + { query: 'test failure create a pull request', combo: 'test_pr_repair', category: 'pr' }, + { query: 'create a PR', combo: 'test_pr_repair', category: 'pr' }, + { query: 'open a pull request', combo: 'test_pr_repair', category: 'pr' }, + { query: '垮我匀 PR', combo: 'test_pr_repair', category: 'pr' }, + { query: 'review this PR for regressions', combo: 'code_review_regression', category: 'review' }, + { query: '审查这䞪 PR 的回園风险', combo: 'code_review_regression', category: 'review' }, + { query: 'code review risk and missing tests', combo: 'code_review_regression', category: 'review' }, + { query: 'production release privacy hook rollback', combo: 'release_public_audit', category: 'release' }, + { query: '检查公匀安装 hook 的隐私和回滚风险然后准倇 release', combo: 'release_public_audit', category: 'release' }, + { query: 'publish npm release with privacy audit', combo: 'release_public_audit', category: 'release' }, + { query: '查 hook  release', combo: 'release_public_audit', category: 'release' }, + { query: '垮我重新规划产品方向和执行方案', combo: 'product_direction_planning', category: 'product' }, + { query: 'replan product direction and execution plan', combo: 'product_direction_planning', category: 'product' }, + { query: 'office hours product strategy', combo: 'product_direction_planning', category: 'product' }, + { query: '请甚 council 议䌚暡匏裁决架构取舍', combo: 'council_escalation', category: 'council', choice: 'mode:council' }, + { query: 'irreversible cost decision council mode', combo: 'council_escalation', category: 'council', choice: 'mode:council' }, + { query: '架构决策䞍可逆议䌚裁决', combo: 'council_escalation', category: 'council', choice: 'mode:council' }, + { query: '这䞪 bug 厩溃了垮我排查报错', combo: 'debug_crash', category: 'debug' }, + { query: 'bug 垮查', combo: 'debug_crash', category: 'debug' }, + { query: 'debug this crash error', combo: 'debug_crash', category: 'debug' }, + { query: 'broken workflow failing command', combo: 'debug_crash', category: 'debug' }, + { query: 'local server stuck with no output', combo: 'debug_stuck_runtime', category: 'debug' }, + { query: '进皋卡䜏长时闎无蟓出', combo: 'debug_stuck_runtime', category: 'debug' }, + { query: 'clean up AI generated slop code', combo: 'refactor_clean', category: 'refactor' }, + { query: '枅理这段臃肿的垃土代码', combo: 'refactor_clean', category: 'refactor' }, + { query: 'refactor this function to simplify duplicated code', combo: 'refactor_clean', category: 'refactor' }, + { query: '检查讀证权限和密钥泄挏安党风险', combo: 'audit_security', category: 'security' }, + { query: 'audit auth permission secret vulnerability', combo: 'audit_security', category: 'security' }, + { query: 'build a new frontend settings screen', combo: 'frontend_new_page', category: 'frontend' }, + { query: '新增䞀䞪前端讟眮页面', combo: 'frontend_new_page', category: 'frontend' }, + { query: 'redesign existing page', combo: 'frontend_existing_redesign', category: 'frontend' }, + { query: '䌘化现有眑页界面', combo: 'frontend_existing_redesign', category: 'frontend' }, + { query: 'build CEO dashboard metrics', combo: 'dashboard_ceo', category: 'dashboard' }, + { query: '把后台改成 CEO dashboard 运营指标看板', combo: 'dashboard_ceo', category: 'dashboard' }, + { query: 'write public install docs README', combo: 'docs_public_install', category: 'docs' }, + { query: '把安装流皋写给普通甚户曎新 README', combo: 'docs_public_install', category: 'docs' }, +] as const; + +export type DogfoodRouteCase = typeof DOGFOOD_ROUTE_CASES[number]; diff --git a/src/orchestrator/route-events.ts b/src/orchestrator/route-events.ts index e7386c6..24c44a1 100644 --- a/src/orchestrator/route-events.ts +++ b/src/orchestrator/route-events.ts @@ -1,20 +1,69 @@ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { ROUTE_EVENTS_PATH } from '../constants.js'; -import type { RouteMode, RouteSpec } from '../types.js'; +import type { ChoiceOption, ChoiceOptionKind, RouteMode, RouteSpec, RouteTarget } from '../types.js'; export type RouteEventSource = 'cli' | 'api' | 'hook-gate' | 'prompt' | 'mcp'; +export type RouteEventAdoptionAction = 'copy_prompt' | 'feedback'; +export type RouteEventFeedbackOutcome = 'accepted' | 'rejected'; +export type RouteEventFeedbackReason = + | 'wrong_skill' + | 'wrong_model' + | 'too_broad' + | 'missed_council' + | 'bad_copy_prompt' + | 'other'; + +export const ROUTE_EVENT_FEEDBACK_REASONS: RouteEventFeedbackReason[] = [ + 'wrong_skill', + 'wrong_model', + 'too_broad', + 'missed_council', + 'bad_copy_prompt', + 'other', +]; + +export interface RouteEventChoiceSummary { + id: string; + kind: ChoiceOptionKind; + label: string; + confidence: number; +} export interface RouteEvent { + eventId: string; timestamp: string; source: RouteEventSource; + target?: RouteTarget; queryHash: string; mode: RouteMode; + intent?: string; combo?: string; + recommendedChoice?: RouteEventChoiceSummary; + topModelChoice?: RouteEventChoiceSummary; + topSkillChoice?: RouteEventChoiceSummary; skillIds: string[]; warningKinds: string[]; semanticWarning: boolean; + adopted?: boolean; + adoptedAt?: string; + adoptedTarget?: RouteTarget; + adoptedChoiceId?: string; + adoptionAction?: RouteEventAdoptionAction; + feedbackOutcome?: RouteEventFeedbackOutcome; + feedbackReason?: RouteEventFeedbackReason; +} + +interface RouteAdoptionLog { + eventType: 'adoption'; + eventId: string; + timestamp: string; + target?: RouteTarget; + choiceId?: string; + action: RouteEventAdoptionAction; + outcome?: RouteEventFeedbackOutcome; + reason?: RouteEventFeedbackReason; } export interface RouteStats { @@ -23,17 +72,41 @@ export interface RouteStats { byMode: Record; topCombos: Array<{ combo: string; count: number }>; semanticWarningCount: number; + adoptedCount: number; + feedbackReasons: Partial>; lastEventAt?: string; } -function ensureParent(): void { - mkdirSync(dirname(ROUTE_EVENTS_PATH), { recursive: true }); +export function isRouteEventFeedbackReason(value: unknown): value is RouteEventFeedbackReason { + return typeof value === 'string' && ROUTE_EVENT_FEEDBACK_REASONS.includes(value as RouteEventFeedbackReason); } export function hashQuery(query: string): string { return createHash('sha1').update(query).digest('hex').slice(0, 16); } +function eventIdFor(input: Pick): string { + return createHash('sha1') + .update(`${input.timestamp}:${input.source}:${input.queryHash}:${input.mode}:${randomUUID()}`) + .digest('hex') + .slice(0, 16); +} + +function choiceSummary(choice: ChoiceOption | undefined): RouteEventChoiceSummary | undefined { + if (!choice) return undefined; + return { + id: choice.id, + kind: choice.kind, + label: choice.label, + confidence: choice.confidence, + }; +} + +function topChoiceOption(spec: RouteSpec, predicate: (choice: ChoiceOption) => boolean): ChoiceOption | undefined { + if (predicate(spec.choices.recommended)) return spec.choices.recommended; + return spec.choices.alternatives.find(predicate); +} + function warningKinds(warnings: string[]): string[] { return [...new Set(warnings.map((warning) => { const lower = warning.toLowerCase(); @@ -48,62 +121,197 @@ function warningKinds(warnings: string[]): string[] { export function recordRouteEvent(input: { query: string; source: RouteEventSource; + target?: RouteTarget; mode: RouteMode; + intent?: string; combo?: string; skillIds?: string[]; warnings?: string[]; -}): void { + recommendedChoice?: ChoiceOption; + topModelChoice?: ChoiceOption; + topSkillChoice?: ChoiceOption; + path?: string; +}): RouteEvent | null { try { const warnings = input.warnings ?? []; + const timestamp = new Date().toISOString(); const event: RouteEvent = { - timestamp: new Date().toISOString(), + eventId: eventIdFor({ + timestamp, + source: input.source, + queryHash: hashQuery(input.query), + mode: input.mode, + }), + timestamp, source: input.source, + target: input.target, queryHash: hashQuery(input.query), mode: input.mode, + intent: input.intent, combo: input.combo, + recommendedChoice: choiceSummary(input.recommendedChoice), + topModelChoice: choiceSummary(input.topModelChoice), + topSkillChoice: choiceSummary(input.topSkillChoice), skillIds: input.skillIds ?? [], warningKinds: warningKinds(warnings), semanticWarning: warnings.some((warning) => /semantic|embedding/i.test(warning)), }; - ensureParent(); - appendFileSync(ROUTE_EVENTS_PATH, JSON.stringify(event) + '\n', 'utf-8'); - } catch {} + ensureParent(input.path); + appendFileSync(input.path ?? ROUTE_EVENTS_PATH, JSON.stringify(event) + '\n', 'utf-8'); + return event; + } catch { + return null; + } } -export function recordRouteSpec(spec: RouteSpec, source: RouteEventSource): void { - recordRouteEvent({ +export function recordRouteSpec(spec: RouteSpec, source: RouteEventSource, path?: string): RouteEvent | null { + return recordRouteEvent({ query: spec.query, source, + target: spec.target, mode: spec.mode, + intent: spec.intent, combo: spec.combo, skillIds: spec.skills.map((skill) => skill.id), - warnings: spec.warnings, + warnings: [...spec.warnings, ...(spec.unlockWarnings ?? [])], + recommendedChoice: spec.choices.recommended, + topModelChoice: topChoiceOption(spec, choice => choice.kind === 'model'), + topSkillChoice: topChoiceOption(spec, choice => ['skill', 'plugin', 'workflow'].includes(choice.kind)), + path, }); } -export function readRouteStats(): RouteStats { +function ensureParent(path = ROUTE_EVENTS_PATH): void { + mkdirSync(dirname(path), { recursive: true }); +} + +function readRouteEventLines(path = ROUTE_EVENTS_PATH): RouteEvent[] { + if (!existsSync(path)) return []; + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean); + const events: RouteEvent[] = []; + const adoptions: RouteAdoptionLog[] = []; + for (const line of lines) { + try { + const event = JSON.parse(line) as Partial & Partial; + if (event.eventType === 'adoption') { + if (!event.eventId || !event.timestamp || !event.action) continue; + adoptions.push({ + eventType: 'adoption', + eventId: event.eventId, + timestamp: event.timestamp, + target: event.target, + choiceId: event.choiceId, + action: event.action, + outcome: event.outcome, + reason: isRouteEventFeedbackReason(event.reason) ? event.reason : undefined, + }); + continue; + } + if (!event.source || !event.mode || !event.queryHash || !event.timestamp) continue; + events.push({ + eventId: event.eventId ?? createHash('sha1').update(`${event.timestamp}:${event.source}:${event.queryHash}`).digest('hex').slice(0, 16), + timestamp: event.timestamp, + source: event.source, + target: event.target, + queryHash: event.queryHash, + mode: event.mode, + intent: event.intent, + combo: event.combo, + recommendedChoice: event.recommendedChoice, + topModelChoice: event.topModelChoice, + topSkillChoice: event.topSkillChoice, + skillIds: Array.isArray(event.skillIds) ? event.skillIds.filter((id): id is string => typeof id === 'string') : [], + warningKinds: Array.isArray(event.warningKinds) ? event.warningKinds.filter((kind): kind is string => typeof kind === 'string') : [], + semanticWarning: Boolean(event.semanticWarning), + adopted: event.adopted, + adoptedAt: event.adoptedAt, + adoptedTarget: event.adoptedTarget, + adoptedChoiceId: event.adoptedChoiceId, + adoptionAction: event.adoptionAction, + feedbackOutcome: event.feedbackOutcome, + feedbackReason: isRouteEventFeedbackReason(event.feedbackReason) ? event.feedbackReason : undefined, + }); + } catch {} + } + const byId = new Map(events.map((event, index) => [event.eventId, index])); + for (const adoption of adoptions) { + const index = byId.get(adoption.eventId); + if (index === undefined) continue; + const previous = events[index]; + events[index] = { + ...previous, + adopted: adoption.action === 'copy_prompt' ? true : previous.adopted, + adoptedAt: adoption.timestamp, + adoptedTarget: adoption.target ?? previous.adoptedTarget, + adoptedChoiceId: adoption.choiceId ?? previous.adoptedChoiceId, + adoptionAction: adoption.action, + feedbackOutcome: adoption.outcome ?? previous.feedbackOutcome, + feedbackReason: adoption.reason ?? previous.feedbackReason, + }; + } + return events; +} + +export function readRecentRouteEvents(input: { limit?: number; path?: string } = {}): RouteEvent[] { + const limit = Math.min(Math.max(input.limit ?? 20, 1), 100); + return readRouteEventLines(input.path).slice(-limit).reverse(); +} + +export function recordRouteAdoption(input: { + eventId: string; + target?: RouteTarget; + choiceId?: string; + action: RouteEventAdoptionAction; + outcome?: RouteEventFeedbackOutcome; + reason?: RouteEventFeedbackReason; + path?: string; +}): RouteEvent | null { + const path = input.path ?? ROUTE_EVENTS_PATH; + const events = readRouteEventLines(path); + if (!events.some(event => event.eventId === input.eventId)) return null; + + try { + const adoption: RouteAdoptionLog = { + eventType: 'adoption', + eventId: input.eventId, + timestamp: new Date().toISOString(), + target: input.target, + choiceId: input.choiceId, + action: input.action, + outcome: input.outcome, + reason: input.reason, + }; + ensureParent(path); + appendFileSync(path, JSON.stringify(adoption) + '\n', 'utf-8'); + return readRouteEventLines(path).find(event => event.eventId === input.eventId) ?? null; + } catch { + return null; + } +} + +export function readRouteStats(path?: string): RouteStats { const stats: RouteStats = { total: 0, bySource: {}, byMode: {}, topCombos: [], semanticWarningCount: 0, + adoptedCount: 0, + feedbackReasons: {}, }; - if (!existsSync(ROUTE_EVENTS_PATH)) return stats; const comboCounts = new Map(); - const lines = readFileSync(ROUTE_EVENTS_PATH, 'utf-8').split('\n').filter(Boolean); - for (const line of lines) { - try { - const event = JSON.parse(line) as Partial; - if (!event.source || !event.mode) continue; - stats.total++; - stats.bySource[event.source] = (stats.bySource[event.source] ?? 0) + 1; - stats.byMode[event.mode] = (stats.byMode[event.mode] ?? 0) + 1; - if (event.semanticWarning) stats.semanticWarningCount++; - if (event.combo) comboCounts.set(event.combo, (comboCounts.get(event.combo) ?? 0) + 1); - if (event.timestamp) stats.lastEventAt = event.timestamp; - } catch {} + for (const event of readRouteEventLines(path)) { + stats.total++; + stats.bySource[event.source] = (stats.bySource[event.source] ?? 0) + 1; + stats.byMode[event.mode] = (stats.byMode[event.mode] ?? 0) + 1; + if (event.semanticWarning) stats.semanticWarningCount++; + if (event.adopted) stats.adoptedCount++; + if (event.feedbackReason) { + stats.feedbackReasons[event.feedbackReason] = (stats.feedbackReasons[event.feedbackReason] ?? 0) + 1; + } + if (event.combo) comboCounts.set(event.combo, (comboCounts.get(event.combo) ?? 0) + 1); + stats.lastEventAt = event.timestamp; } stats.topCombos = [...comboCounts.entries()] .sort((a, b) => b[1] - a[1]) diff --git a/src/orchestrator/route-gate.ts b/src/orchestrator/route-gate.ts index 4dc00bf..99dddb6 100644 --- a/src/orchestrator/route-gate.ts +++ b/src/orchestrator/route-gate.ts @@ -9,8 +9,9 @@ export interface RouteGateDecision { reason: string; } -const COMPLEX_PATTERN = /\b(dashboard|redesign|frontend|ui|ux|review|regression|debug|bug|stuck|hang|release|publish|audit|privacy|rollback|hook|agent|team|subagent|multi-agent|mcp|embedding|semantic|architecture|refactor|migration|docs|readme|test|build|lint|ci|workflow)\b|看板|仪衚盘|页面|界面|前端|重构|审查|回園|排查|调试|卡䜏|无蟓出|发垃|公匀|隐私|回滚|安装|钩子|hook|智胜䜓|子智胜䜓|倚智胜䜓|猖排|架构|迁移|文档|测试|构建|莚量|审栞|讟计䞀䞪|讟计䞪|写䞀䞪|写䞪|做䞪|做䞀䞪|垮我写|垮我做|垮我改|垮我讟计/iu; -const HIGH_RISK_PATTERN = /\b(delete|remove|reset|force push|global|publish|release|secret|token|credential|private|rollback|hook|install|production|prod|deploy)\b|删陀|枅理|重眮|区掚|å…šå±€|发垃|生产|密钥|隐私|回滚|安装|钩子|hook/iu; +const COUNCIL_PATTERN = /\b(council|council mode|escalation|tradeoff|trade-off|irreversible|architecture decision|cost decision)\b|议䌚|議會|议䌚暡匏|議會暡匏|取舍|取捚|裁决|裁決|䞍可逆|架构决策|架構決策|成本决策|成本決策/iu; +const COMPLEX_PATTERN = /\b(dashboard|redesign|frontend|ui|ux|review|regression|debug|bug|stuck|hang|release|publish|audit|privacy|rollback|hook|agent|team|subagent|multi-agent|mcp|embedding|semantic|architecture|refactor|migration|docs|readme|test|build|lint|ci|workflow|pull request|pr|council|escalation|tradeoff|trade-off|architecture decision|cost decision)\b|看板|仪衚盘|页面|界面|前端|重构|审查|回園|排查|调试|卡䜏|无蟓出|发垃|公匀|隐私|回滚|安装|钩子|hook|智胜䜓|子智胜䜓|倚智胜䜓|猖排|架构|迁移|文档|测试|构建|莚量|审栞|匀\s*PR|创建\s*PR|发\s*PR|提\s*PR|议䌚|議會|取舍|取捚|裁决|裁決|䞍可逆|讟计䞀䞪|讟计䞪|写䞀䞪|写䞪|做䞪|做䞀䞪|垮我写|垮我做|垮我改|垮我讟计/iu; +const HIGH_RISK_PATTERN = /\b(delete|remove|reset|force push|global|publish|release|secret|token|credential|private|rollback|hook|install|production|prod|deploy|irreversible)\b|删陀|枅理|重眮|区掚|å…šå±€|发垃|生产|密钥|隐私|回滚|安装|钩子|hook|䞍可逆/iu; const VAGUE_PATTERN = /有点乱|怎么安排|䜠看怎么|看侀例|垮我看看|䞍知道|随䟿|䌘化䞀䞋|匄䞀䞋|搞䞀䞋|䞍倪懂|暡糊|先看看|\b(fix this|make it better|clean this up|help me|figure it out|take a look)\b/iu; const SIMPLE_PATTERN = /\b(what is|who is|translate|rename|typo|fix typo|change text|small copy|current time|date)\b|是什么|是谁|几点|日期|翻译|错别字|改文案|按钮文字|改䞪字|小改/iu; @@ -26,10 +27,20 @@ export function classifyRouteNeed(query: string): RouteGateDecision { } const highRisk = HIGH_RISK_PATTERN.test(q); + const council = COUNCIL_PATTERN.test(q); const complex = COMPLEX_PATTERN.test(q); const vague = VAGUE_PATTERN.test(q); const simple = SIMPLE_PATTERN.test(q) && !complex && !highRisk; + if (council && !highRisk) { + return { + mode: 'route_plan', + shouldCallLazyBrain: true, + category: 'complex', + reason: 'The task asks for council-style escalation where routing should frame options, tradeoffs, and verification before deciding.', + }; + } + if (vague && !complex && !highRisk) { return { mode: 'needs_clarification', diff --git a/src/orchestrator/route.ts b/src/orchestrator/route.ts index de191de..abfc1e3 100644 --- a/src/orchestrator/route.ts +++ b/src/orchestrator/route.ts @@ -7,10 +7,13 @@ import type { Capability, + ChoiceOption, + ChoiceSet, + ConflictNotice, + DecisionPolicy, GuardrailRule, HistoryEntry, Recommendation, - RouteAdapterPayload, RouteSkillRef, RouteSpec, RouteTarget, @@ -23,9 +26,10 @@ import type { } from '../types.js'; import { Graph } from '../graph/graph.js'; import { match } from '../matcher/matcher.js'; -import { findCombo, type ComboTemplate } from '../combos/registry.js'; +import { findCombo, formatComboEntryCommand, type ComboTemplate } from '../combos/registry.js'; import { getVerificationBundle } from '../verification/catalog.js'; -import { classifyRouteNeed } from './route-gate.js'; +import { classifyRouteNeed, type RouteGateDecision } from './route-gate.js'; +import { getEmbeddingCacheStatus } from '../embeddings/cache.js'; export interface BuildRouteSpecOptions { graph: Graph; @@ -36,7 +40,12 @@ export interface BuildRouteSpecOptions { } const TARGETS: RouteTarget[] = ['generic', 'claude', 'codex', 'cursor']; -export const ROUTE_SPEC_SCHEMA_VERSION = '1.4.5'; +export const ROUTE_SPEC_SCHEMA_VERSION = '1.5.0'; + +type RouteSpecDraft = Omit; +type ChoiceContext = { + gate: RouteGateDecision; +}; export function isRouteTarget(value: string): value is RouteTarget { return TARGETS.includes(value as RouteTarget); @@ -54,6 +63,23 @@ function unique(items: T[], key: (item: T) => string): T[] { return out; } +function uniquePreferLast(items: T[], key: (item: T) => string): T[] { + const indexes = new Map(); + const out: T[] = []; + for (const item of items) { + const k = key(item).trim().toLowerCase(); + if (!k) continue; + const existing = indexes.get(k); + if (existing !== undefined) { + out[existing] = item; + continue; + } + indexes.set(k, out.length); + out.push(item); + } + return out; +} + function isVagueQuery(query: string): boolean { const q = query.trim().toLowerCase(); const vague = /有点乱|怎么安排|䜠看怎么|看侀例|垮我看看|䞍知道|随䟿|䌘化䞀䞋|匄䞀䞋|搞䞀䞋/.test(q) || @@ -97,6 +123,9 @@ function toSkillRef(cap: Capability, result?: Recommendation['matches'][number], kind: cap.kind, category: cap.category, origin: cap.origin, + provider: cap.provider, + conflictGroup: cap.conflictGroup, + sideEffects: cap.sideEffects, available: true, score: result?.score, layer: result?.layer, @@ -104,6 +133,27 @@ function toSkillRef(cap: Capability, result?: Recommendation['matches'][number], }; } +function isProviderSpecificCodeGraphCapability(cap: Capability): boolean { + const text = [ + cap.id, + cap.name, + cap.origin, + cap.provider, + cap.description, + cap.scenario, + ...(cap.tags ?? []), + ].filter(Boolean).join(' ').toLowerCase(); + return /\bgitnexus\b|gitnexus-/.test(text); +} + +function queryMentionsCodeGraphProvider(query: string): boolean { + return /\bgit\s*nexus\b|\bgitnexus\b/i.test(query); +} + +function shouldExposeCapabilityInRoute(query: string, cap: Capability): boolean { + return !isProviderSpecificCodeGraphCapability(cap) || queryMentionsCodeGraphProvider(query); +} + function missingSkillRef(name: string, category: string, reason: string): RouteSkillRef { return { id: `missing:${name}`, @@ -111,12 +161,30 @@ function missingSkillRef(name: string, category: string, reason: string): RouteS kind: 'skill', category, origin: 'combo', + provider: 'combo', + conflictGroup: `skill:${name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')}`, available: false, reason, }; } -function buildSkillRefs(graph: Graph, rec: Recommendation, combo?: ComboTemplate): RouteSkillRef[] { +function explicitSkillRef( + cap: Capability, + result: Recommendation['matches'][number] | undefined, +): RouteSkillRef { + const ref = toSkillRef( + cap, + result, + result?.explanation ?? 'Explicitly named in the query; keep it visible even before embedding coverage catches up.', + ); + if (!result) { + ref.score = 0.92; + ref.layer = 'alias'; + } + return ref; +} + +function buildSkillRefs(graph: Graph, rec: Recommendation, combo: ComboTemplate | undefined, query: string): RouteSkillRef[] { const refs: RouteSkillRef[] = []; const resultById = new Map(rec.matches.map(result => [result.capability.id, result])); @@ -129,21 +197,41 @@ function buildSkillRefs(graph: Graph, rec: Recommendation, combo?: ComboTemplate } } - for (const result of rec.matches.slice(0, 5)) { + for (const result of rec.matches.filter(result => shouldExposeCapabilityInRoute(query, result.capability)).slice(0, 5)) { refs.push(toSkillRef(result.capability, result)); } - return unique(refs, item => item.id); + const explicitlyNamed = graph.getAllNodes() + .filter(cap => cap.status !== 'disabled' && queryMentionsCapability(query, cap)) + .filter(cap => shouldExposeCapabilityInRoute(query, cap)) + .slice(0, 8); + for (const cap of explicitlyNamed) { + refs.push(explicitSkillRef(cap, resultById.get(cap.id))); + } + + return unique(refs, item => item.name); +} + +function routeUnlockWarnings(graph: Graph): string[] { + const embedding = getEmbeddingCacheStatus(graph.getAllNodes()); + if (embedding.state === 'ok') return []; + if (embedding.state === 'stale') { + const missing = embedding.missingIds.length > 0 ? ` Missing embeddings: ${embedding.missingIds.slice(0, 3).join(', ')}${embedding.missingIds.length > 3 ? ', ...' : ''}.` : ''; + return [`Embedding cache is partial (${embedding.covered}/${embedding.active}, ${embedding.coveragePercent}%). Tag/combo routing stays active; semantic matches are down-weighted.${missing}`]; + } + if (embedding.state === 'missing') return ['Embedding cache is missing. Tag/combo routing stays active; run lazybrain embeddings rebuild --yes to enable semantic boost.']; + return ['Embedding cache is invalid. Tag/combo routing stays active; rebuild embeddings to restore semantic boost.']; } function fallbackWorkflow(query: string, rec: Recommendation): WorkflowStep[] { const top = rec.matches[0]?.capability; + const detail = compactReason(top?.scenario ?? top?.description ?? query); return [ { id: 'clarify-task-surface', title: 'Confirm the target surface and expected output', source: 'fallback' }, { id: 'apply-primary-capability', title: top ? `Use ${top.name} for the main task` : 'Use the best matched capability for the main task', - detail: top?.scenario ?? top?.description ?? query, + detail, source: 'fallback', }, { id: 'verify-result', title: 'Run the relevant verification before calling the task done', source: 'fallback' }, @@ -176,7 +264,7 @@ function mergeGuardrails(...groups: Array): Guardra } function mergeVerification(...groups: Array): VerificationRequirement[] { - return unique(groups.flatMap(group => group ?? []), item => item.id ?? item.title); + return uniquePreferLast(groups.flatMap(group => group ?? []), item => item.command ?? item.id ?? item.title); } function adapterPrompt(spec: Omit, target: RouteTarget): string { @@ -194,15 +282,33 @@ function adapterPrompt(spec: Omit, target: RouteTarget): `Mode: ${spec.mode}`, `Why route: ${spec.whyRoute}`, ]; + if (spec.entryCommand) lines.push(`Entry command: ${spec.entryCommand}`); + if (spec.executionMode) lines.push(`Execution mode: ${spec.executionMode}`); + if (spec.modelStrategy) lines.push(`Model strategy: ${spec.modelStrategy}`); + + lines.push(`Recommended choice: ${spec.choices.recommended.label} (${spec.choices.recommended.kind})`); + if (spec.choices.alternatives.length > 0) { + lines.push('Alternatives:'); + for (const choice of spec.choices.alternatives.slice(0, 3)) { + lines.push(`- ${choice.label} (${choice.kind}, ${Math.round(choice.confidence * 100)}%)`); + } + } + if (spec.choices.conflicts.length > 0) { + lines.push('Conflict notices:'); + for (const conflict of spec.choices.conflicts.slice(0, 3)) { + lines.push(`- ${conflict.group}: ${conflict.reason}`); + } + } lines.push('', 'Token strategy:'); lines.push(`- Top-K skills: ${spec.tokenStrategy.topKSkills}`); lines.push(`- Full skill body: ${spec.tokenStrategy.includeFullSkillBody ? 'yes' : 'no'}`); lines.push(`- Context budget: ${spec.tokenStrategy.contextBudget}`); - if (spec.skills.length > 0) { + const promptSkills = primaryRouteSkills(spec); + if (promptSkills.length > 0) { lines.push('', 'Use:'); - for (const skill of spec.skills) { + for (const skill of promptSkills) { lines.push(`- ${skill.name}${skill.available ? '' : ' (missing: use a generic prompt)'}`); } } @@ -245,13 +351,12 @@ function adapterPrompt(spec: Omit, target: RouteTarget): } function buildAdapters(spec: Omit): RouteSpec['adapters'] { - const adapters: RouteSpec['adapters'] = { + return { generic: { target: 'generic', prompt: adapterPrompt(spec, 'generic') }, + claude: { target: 'claude', prompt: adapterPrompt(spec, 'claude') }, + codex: { target: 'codex', prompt: adapterPrompt(spec, 'codex') }, + cursor: { target: 'cursor', prompt: adapterPrompt(spec, 'cursor') }, }; - if (spec.target !== 'generic') { - adapters[spec.target] = { target: spec.target, prompt: adapterPrompt(spec, spec.target) } as RouteAdapterPayload; - } - return adapters; } function needsClarification(query: string, rec: Recommendation, combo?: ComboTemplate): boolean { @@ -263,9 +368,10 @@ function needsClarification(query: string, rec: Recommendation, combo?: ComboTem } function shouldSuggestSubagents(query: string, combo?: ComboTemplate): boolean { - return /\b(team|subagent|multi-agent|parallel|agents?)\b|智胜䜓|子智胜䜓|团队|并行|审查|评审/iu.test(query) || + return /\b(team|subagent|multi-agent|parallel|agents?|council|escalation)\b|智胜䜓|子智胜䜓|团队|并行|审查|评审|议䌚|議會|裁决|裁決|取舍|取捚/iu.test(query) || combo?.id === 'code_review_regression' || - combo?.id === 'release_public_audit'; + combo?.id === 'release_public_audit' || + combo?.id === 'council_escalation'; } function tokenStrategyFor(input: { @@ -292,15 +398,436 @@ function tokenStrategyFor(input: { ? 'Clarify before loading skill context.' : input.mode === 'no_route_needed' ? 'Handle directly; no skill body should be loaded.' - : `Load only ${topKSkills} compact skill card${topKSkills === 1 ? '' : 's'} plus verification guidance.`, + : `Load only ${topKSkills} compact skill card${topKSkills === 1 ? '' : 's'} plus verification guidance.`, + }; +} + +function clampConfidence(value: number | undefined, fallback: number): number { + const raw = Number.isFinite(value) ? value as number : fallback; + return Math.max(0, Math.min(1, Math.round(raw * 100) / 100)); +} + +function routeCostFromCapability(cap: Capability | undefined): ChoiceOption['cost'] { + if (cap?.costLevel === 'high') return 'high'; + if (cap?.costLevel === 'medium') return 'medium'; + return 'low'; +} + +function routeRiskFromCapability(cap: Capability | undefined, available: boolean): ChoiceOption['risk'] { + if (!available) return 'medium'; + if (cap?.requiresConfirmation || cap?.riskLevel === 'destructive') return 'high'; + if (cap?.riskLevel === 'caution') return 'medium'; + return 'low'; +} + +function latencyFromCost(cost: ChoiceOption['cost']): ChoiceOption['latency'] { + if (cost === 'high') return 'slow'; + if (cost === 'medium') return 'normal'; + return 'fast'; +} + +function choiceKindForSkill(skill: RouteSkillRef): ChoiceOption['kind'] { + if (skill.kind === 'mode') return 'mode'; + return (skill.provider ?? skill.origin).toLowerCase().includes('plugin') ? 'plugin' : 'skill'; +} + +function skillChoice(skill: RouteSkillRef, graph: Graph, fallbackConfidence: number): ChoiceOption { + const cap = graph.getNode(skill.id); + const cost = routeCostFromCapability(cap); + return { + id: `${choiceKindForSkill(skill)}:${skill.id}`, + kind: choiceKindForSkill(skill), + label: skill.name, + confidence: clampConfidence(skill.score, fallbackConfidence), + cost, + latency: latencyFromCost(cost), + risk: routeRiskFromCapability(cap, skill.available), + reason: skill.reason ?? `${skill.name} is a matched capability for this route.`, + }; +} + +function modeChoice(mode: RouteSpec['mode'], confidence: number, reason: string): ChoiceOption { + if (mode === 'no_route_needed') { + return { + id: 'mode:direct', + kind: 'mode', + label: 'Direct execution', + confidence: clampConfidence(confidence, 0.9), + cost: 'low', + latency: 'fast', + risk: 'low', + reason, + }; + } + if (mode === 'needs_clarification') { + return { + id: 'mode:clarify-first', + kind: 'mode', + label: 'Clarify before routing', + confidence: clampConfidence(confidence, 0.85), + cost: 'low', + latency: 'fast', + risk: 'low', + reason, + }; + } + return { + id: 'mode:route-plan', + kind: 'mode', + label: 'Route plan', + confidence: clampConfidence(confidence, 0.75), + cost: 'low', + latency: 'normal', + risk: 'medium', + reason, + }; +} + +function modelChoice(modelStrategy: string | undefined, highRisk: boolean): ChoiceOption { + if (modelStrategy) { + const strong = /strong|deep|senior|review|audit|security|architecture|高|æ·±|区|审查|安党|架构/i.test(modelStrategy); + return { + id: 'model:recommended-strategy', + kind: 'model', + label: strong ? 'Stronger reasoning model' : 'Balanced model strategy', + confidence: strong ? 0.82 : 0.75, + cost: strong ? 'high' : 'medium', + latency: strong ? 'slow' : 'normal', + risk: 'low', + reason: modelStrategy, + }; + } + if (highRisk) { + return { + id: 'model:strong-reasoning', + kind: 'model', + label: 'Stronger reasoning model', + confidence: 0.78, + cost: 'high', + latency: 'slow', + risk: 'low', + reason: 'The route contains high-risk capabilities, so the model strategy should favor stronger reasoning and review.', + }; + } + return { + id: 'model:balanced', + kind: 'model', + label: 'Balanced coding model', + confidence: 0.7, + cost: 'medium', + latency: 'normal', + risk: 'low', + reason: 'The task is non-trivial but does not require an expensive model by default.', + }; +} + +function hasSensitiveDataSignal(query: string): boolean { + return /secret|token|credential|private|privacy|密钥|隐私/i.test(query); +} + +function rankedModelChoices(draft: RouteSpecDraft, highRisk: boolean): ChoiceOption[] { + const recommended = modelChoice(draft.modelStrategy, highRisk); + const sensitive = hasSensitiveDataSignal(draft.query); + const fast: ChoiceOption = { + id: 'model:fast-low-cost', + kind: 'model', + label: 'Fast low-cost model', + confidence: highRisk ? 0.38 : 0.62, + cost: 'low', + latency: 'fast', + risk: highRisk ? 'medium' : 'low', + reason: highRisk + ? 'Available only as a fallback because this task has high-risk signals.' + : 'Good for small implementation, docs, and repeatable verification work.', + }; + const balanced: ChoiceOption = { + id: 'model:balanced', + kind: 'model', + label: 'Balanced coding model', + confidence: highRisk ? 0.64 : 0.76, + cost: 'medium', + latency: 'normal', + risk: 'low', + reason: 'Default fit for normal coding, review, debugging, and documentation tasks.', + }; + const strong: ChoiceOption = { + id: 'model:strong-reasoning', + kind: 'model', + label: 'Stronger reasoning model', + confidence: highRisk ? 0.86 : 0.58, + cost: 'high', + latency: 'slow', + risk: 'low', + reason: highRisk + ? 'Recommended for high-risk changes, releases, security, production, hooks, and irreversible operations.' + : 'Use when architecture, subtle bugs, or cross-module tradeoffs matter more than cost.', + }; + const localPrivate: ChoiceOption = { + id: 'model:local-private', + kind: 'model', + label: 'Local or private model', + confidence: sensitive ? 0.72 : 0.45, + cost: 'low', + latency: 'normal', + risk: 'low', + reason: 'Prefer this when sensitive data should stay local or inside a private runtime.', + }; + const ordered = highRisk && sensitive + ? [strong, localPrivate, recommended, balanced, fast] + : highRisk + ? [strong, recommended, balanced, localPrivate, fast] + : [recommended, balanced, fast, strong, localPrivate]; + return uniqueChoices(ordered); +} + +function wantsMode(query: string, pattern: RegExp): boolean { + return pattern.test(query); +} + +function wantsCouncil(query: string, combo?: string): boolean { + return combo === 'council_escalation' || + /\b(council|council mode|escalation|tradeoff|trade-off|irreversible|architecture decision|cost decision)\b|议䌚|議會|议䌚暡匏|議會暡匏|取舍|取捚|裁决|裁決|䞍可逆|架构决策|架構決策|成本决策|成本決策/iu.test(query); +} + +function rankedModeChoices(draft: RouteSpecDraft, highRisk: boolean): ChoiceOption[] { + const q = draft.query; + const base = modeChoice(draft.mode, draft.mode === 'route_plan' ? 0.76 : 0.9, draft.whyRoute); + const councilWanted = wantsCouncil(q, draft.combo); + const council: ChoiceOption = { + id: 'mode:council', + kind: 'mode', + label: 'Council mode', + confidence: councilWanted ? 0.84 : 0.38, + cost: 'high', + latency: 'slow', + risk: councilWanted || highRisk ? 'medium' : 'low', + reason: 'Use this for architecture, cost, product, or irreversible tradeoffs that need multi-perspective review before a decision.', + }; + const review: ChoiceOption = { + id: 'mode:review', + kind: 'mode', + label: 'Review mode', + confidence: wantsMode(q, /review|audit|security|regression|审查|审栞|安党|回園/i) || highRisk ? 0.78 : 0.48, + cost: 'medium', + latency: 'normal', + risk: 'low', + reason: 'Use this when the main value is catching regressions, security issues, or risky assumptions before execution.', + }; + const qa: ChoiceOption = { + id: 'mode:qa', + kind: 'mode', + label: 'QA mode', + confidence: wantsMode(q, /test|qa|verify|build|lint|ci|release|publish|测试|验证|构建|发垃/i) ? 0.74 : 0.5, + cost: 'medium', + latency: 'normal', + risk: 'low', + reason: 'Use this when verification evidence matters as much as the code or plan.', + }; + const autopilot: ChoiceOption = { + id: 'mode:autopilot', + kind: 'mode', + label: 'Autopilot mode', + confidence: wantsMode(q, /autopilot|auto\s*pilot|end-to-end|end to end|党自劚|自劚完成|自劚跑完|端到端|自己安排/i) ? 0.76 : 0.36, + cost: 'high', + latency: 'slow', + risk: 'high', + reason: 'Use only when the customer wants an end-to-end autonomous loop with checkpoints and handoff records.', + }; + const team: ChoiceOption = { + id: 'mode:team', + kind: 'mode', + label: 'Team mode', + confidence: wantsMode(q, /team|subagent|multi-agent|parallel|团队|子智胜䜓|倚智胜䜓|并行/i) ? 0.74 : 0.34, + cost: 'high', + latency: 'slow', + risk: 'medium', + reason: 'Use when independent subtasks can run in parallel without creating file ownership conflicts.', + }; + + if (draft.mode === 'no_route_needed') { + return uniqueChoices([ + base, + { + id: 'mode:route-plan-if-task-grows', + kind: 'mode', + label: 'Route if task grows', + confidence: 0.48, + cost: 'low', + latency: 'normal', + risk: 'low', + reason: 'Use route planning only if the direct task expands into coding, review, testing, or release work.', + }, + ]); + } + + if (draft.mode === 'needs_clarification') { + return uniqueChoices([ + base, + ...[council, autopilot, team].filter(choice => choice.confidence >= 0.7), + { + id: 'mode:route-plan-after-clarification', + kind: 'mode', + label: 'Route after clarification', + confidence: 0.62, + cost: 'low', + latency: 'normal', + risk: 'medium', + reason: 'After the target output is clear, run route planning with the clarified task.', + }, + ]); + } + + return uniqueChoices([base, ...(councilWanted ? [council] : []), review, qa, autopilot, team]) + .sort((a, b) => b.confidence - a.confidence); +} + +function workflowChoice(draft: RouteSpecDraft): ChoiceOption { + return { + id: draft.combo ? `workflow:${draft.combo}` : 'workflow:route-plan', + kind: 'workflow', + label: draft.combo ?? draft.intent, + confidence: draft.combo ? 0.86 : 0.72, + cost: 'low', + latency: 'normal', + risk: draft.guardrails.some(rule => rule.strength === 'strict') ? 'medium' : 'low', + reason: draft.combo + ? `Matched built-in workflow ${draft.combo}.` + : 'Use the generated route plan, compact context, and listed verification.', + command: draft.entryCommand, + }; +} + +function uniqueChoices(items: ChoiceOption[]): ChoiceOption[] { + return unique(items, item => item.id); +} + +function choiceConflicts(skills: RouteSkillRef[], skillChoices: ChoiceOption[]): ConflictNotice[] { + const conflicts: ConflictNotice[] = []; + const available = skillChoices.filter(choice => !choice.id.startsWith('skill:missing:')); + const choiceBySkillId = new Map(skillChoices.map(choice => [choice.id.split(':').slice(1).join(':'), choice])); + const byConflictGroup = new Map(); + for (const skill of skills) { + if (!skill.available || !skill.conflictGroup) continue; + const items = byConflictGroup.get(skill.conflictGroup) ?? []; + items.push(skill); + byConflictGroup.set(skill.conflictGroup, items); + } + for (const [group, items] of byConflictGroup) { + if (items.length < 2) continue; + const winner = choiceBySkillId.get(items[0].id); + if (!winner) continue; + conflicts.push({ + group, + winner: winner.id, + suppressed: items.slice(1).map(skill => choiceBySkillId.get(skill.id)?.id).filter((id): id is string => Boolean(id)), + reason: 'Multiple matched capabilities share a registry conflict group; route should use the winner first and keep others as alternatives.', + suggestedAction: 'Use the winner for initial context. Select a suppressed provider only if its provider, platform, or side effects fit better; do not chain conflicting providers automatically.', + severity: 'warn', + }); + } + if (available.length > 1) { + conflicts.push({ + group: 'skill:same-intent', + winner: available[0].id, + suppressed: available.slice(1, 4).map(choice => choice.id), + reason: 'Only the top matched capability should drive initial context; alternatives remain available in choices.', + suggestedAction: 'Auto-use the winner and keep alternatives visible for manual override; no user prompt is needed for this informational overlap.', + severity: 'info', + }); + } + const missing = skills.filter(skill => !skill.available); + if (missing.length > 0) { + conflicts.push({ + group: 'skill:missing', + winner: available[0]?.id ?? 'mode:route-plan', + suppressed: missing.map(skill => `skill:${skill.id}`), + reason: 'Some recommended combo roles are not installed, so the route should fall back to available capabilities or the generic workflow.', + suggestedAction: 'Continue with the available winner, or install the missing capability before rerunning the route if that role is required.', + severity: 'warn', + }); + } + return conflicts; +} + +function decisionPolicy(draft: RouteSpecDraft, highRisk: boolean): DecisionPolicy { + if (draft.mode === 'needs_clarification') { + return { + defaultAction: 'ask', + askUser: true, + reason: 'The request is too broad or low-confidence; clarify before spending context or selecting tools.', + }; + } + if (highRisk) { + return { + defaultAction: 'ask', + askUser: true, + reason: 'A matched capability is destructive or requires confirmation, so execution should pause for approval.', + }; + } + if (draft.mode === 'no_route_needed') { + return { + defaultAction: 'auto', + askUser: false, + reason: 'The task is small enough to handle directly without loading routing context.', + }; + } + return { + defaultAction: 'auto', + askUser: false, + reason: 'Use the recommended route by default; alternatives are advisory unless the caller has stricter policy.', }; } +function buildChoiceSet(draft: RouteSpecDraft, graph: Graph, context: ChoiceContext): ChoiceSet { + const skillChoices = draft.skills.slice(0, 5).map((skill, index) => skillChoice(skill, graph, 0.68 - (index * 0.06))); + const highRisk = context.gate.category === 'high_risk' || skillChoices.some(choice => choice.risk === 'high'); + const modelChoices = rankedModelChoices(draft, highRisk); + const modeChoices = rankedModeChoices(draft, highRisk); + const primaryMode = modeChoices[0] ?? modeChoice(draft.mode, draft.mode === 'route_plan' ? 0.76 : 0.9, draft.whyRoute); + + let recommended: ChoiceOption; + const alternatives: ChoiceOption[] = []; + + if (draft.mode === 'route_plan') { + const workflow = workflowChoice(draft); + recommended = draft.combo ? workflow : skillChoices[0] ?? workflow; + alternatives.push( + ...modelChoices.slice(0, 2), + ...modeChoices, + workflow, + ...modelChoices.slice(2, 4), + ...skillChoices, + ); + } else if (draft.mode === 'needs_clarification') { + recommended = primaryMode; + alternatives.push(...modeChoices.slice(1), ...modelChoices); + } else { + recommended = primaryMode; + alternatives.push(...modeChoices.slice(1)); + } + + return { + intent: draft.intent, + recommended, + alternatives: uniqueChoices(alternatives).filter(choice => choice.id !== recommended.id).slice(0, 8), + conflicts: choiceConflicts(draft.skills, skillChoices), + policy: decisionPolicy(draft, highRisk), + }; +} + +function finalizeRouteSpec(draft: RouteSpecDraft, graph: Graph, context: ChoiceContext): RouteSpec { + const withChoices: Omit = { + ...draft, + choices: buildChoiceSet(draft, graph, context), + }; + return { ...withChoices, adapters: buildAdapters(withChoices) }; +} + export async function buildRouteSpec(query: string, options: BuildRouteSpecOptions): Promise { const target = options.target ?? 'generic'; const gate = classifyRouteNeed(query); if (gate.mode === 'no_route_needed') { - const partial: Omit = { + const draft: RouteSpecDraft = { schemaVersion: ROUTE_SPEC_SCHEMA_VERSION, query, target, @@ -318,8 +845,9 @@ export async function buildRouteSpec(query: string, options: BuildRouteSpecOptio doneWhen: ['The direct answer or tiny edit is complete.'], tokenStrategy: tokenStrategyFor({ mode: 'no_route_needed', skills: [], query }), warnings: [], + unlockWarnings: routeUnlockWarnings(options.graph), }; - return { ...partial, adapters: buildAdapters(partial) }; + return finalizeRouteSpec(draft, options.graph, { gate }); } const rec = await match(query, { @@ -330,14 +858,16 @@ export async function buildRouteSpec(query: string, options: BuildRouteSpecOptio }); const categories = rec.matches.map(result => result.capability.category); const combo = findCombo(query, categories); - const skills = buildSkillRefs(options.graph, rec, combo); + const skills = buildSkillRefs(options.graph, rec, combo, query); const schemas = collectSchemas(skills, options.graph); const catalog = getVerificationBundle({ query, category: categories[0], comboId: combo?.id }); const schemaWarnings = schemas.flatMap(schema => schema.warnings ?? []); const warnings = unique([...(rec.warnings ?? []), ...schemaWarnings], item => item); + const unlockWarnings = routeUnlockWarnings(options.graph); if (needsClarification(query, rec, combo)) { - const partial: Omit = { + const visibleNamedSkills = skills.filter(skill => queryMentionsSkill(query, skill)); + const draft: RouteSpecDraft = { schemaVersion: ROUTE_SPEC_SCHEMA_VERSION, query, target, @@ -346,7 +876,7 @@ export async function buildRouteSpec(query: string, options: BuildRouteSpecOptio scenario: 'The request is too broad or low-confidence for a reliable skill chain.', whyRoute: gate.reason, mustCallLazyBrainReason: 'Clarification should happen before the main model spends context on a guessed skill chain.', - skills: [], + skills: visibleNamedSkills, executionPlan: [], contextNeeded: [], guardrails: [ @@ -356,12 +886,16 @@ export async function buildRouteSpec(query: string, options: BuildRouteSpecOptio doneWhen: ['The user or main model has clarified the target output and verification method.'], tokenStrategy: tokenStrategyFor({ mode: 'needs_clarification', skills: [], query, combo }), warnings, + unlockWarnings, clarificationQuestions: clarificationQuestions(query), }; - return { ...partial, adapters: buildAdapters(partial) }; + return finalizeRouteSpec(draft, options.graph, { gate }); } const top = rec.matches[0]?.capability; + const fallbackScenario = top + ? compactReason(top.scenario ?? top.description, 260) + : undefined; const workflow = mergeWorkflow(query, rec, combo, schemas); const contextNeeded = mergeStrings( combo?.contextNeeded, @@ -385,18 +919,21 @@ export async function buildRouteSpec(query: string, options: BuildRouteSpecOptio catalog.doneWhen, ); - const partial: Omit = { + const draft: RouteSpecDraft = { schemaVersion: ROUTE_SPEC_SCHEMA_VERSION, query, target, mode: 'route_plan', intent: combo?.title ?? top?.name ?? 'Route task', - scenario: combo?.description ?? top?.scenario ?? top?.description ?? 'Advisory route plan', + scenario: combo?.description ?? fallbackScenario ?? 'Advisory route plan', whyRoute: combo ? `Matched built-in combo ${combo.id}; compact routing can reduce context and attach verification.` : gate.reason, mustCallLazyBrainReason: 'Use LazyBrain when routing skills, agents, verification, or context reduction can materially help.', combo: combo?.id, + entryCommand: combo ? formatComboEntryCommand(combo, target) : undefined, + executionMode: combo?.executionMode, + modelStrategy: combo?.modelStrategy, skills, executionPlan: workflow, contextNeeded, @@ -405,9 +942,10 @@ export async function buildRouteSpec(query: string, options: BuildRouteSpecOptio doneWhen, tokenStrategy: tokenStrategyFor({ mode: 'route_plan', skills, query, combo }), warnings, + unlockWarnings, }; - return { ...partial, adapters: buildAdapters(partial) }; + return finalizeRouteSpec(draft, options.graph, { gate }); } export function formatRouteSpec(spec: RouteSpec): string { @@ -419,6 +957,25 @@ export function formatRouteSpec(spec: RouteSpec): string { `Why: ${spec.whyRoute}`, ]; if (spec.combo) lines.push(`Combo: ${spec.combo}`); + if (spec.entryCommand) lines.push(`Entry command: ${spec.entryCommand}`); + if (spec.executionMode) lines.push(`Execution mode: ${spec.executionMode}`); + if (spec.modelStrategy) lines.push(`Model strategy: ${spec.modelStrategy}`); + + lines.push('', 'Choice:'); + lines.push(` - Recommended: ${spec.choices.recommended.label} [${spec.choices.recommended.kind}, ${Math.round(spec.choices.recommended.confidence * 100)}%]`); + if (spec.choices.recommended.command) lines.push(` Command: ${spec.choices.recommended.command}`); + if (spec.choices.alternatives.length > 0) { + lines.push(' - Alternatives:'); + for (const choice of spec.choices.alternatives.slice(0, 3)) { + lines.push(` - ${choice.label} [${choice.kind}, ${Math.round(choice.confidence * 100)}%]`); + } + } + if (spec.choices.conflicts.length > 0) { + lines.push(' - Conflict notices:'); + for (const conflict of spec.choices.conflicts.slice(0, 3)) { + lines.push(` - ${conflict.group}: ${conflict.reason}`); + } + } lines.push('', 'Token strategy:'); lines.push(` - Top-K skills: ${spec.tokenStrategy.topKSkills}`); @@ -431,6 +988,10 @@ export function formatRouteSpec(spec: RouteSpec): string { lines.push('', 'Warnings:'); for (const warning of spec.warnings) lines.push(` - ${warning}`); } + if (spec.unlockWarnings?.length) { + lines.push('', 'Unlock warnings:'); + for (const warning of spec.unlockWarnings) lines.push(` - ${warning}`); + } if (spec.clarificationQuestions?.length) { lines.push('', 'Clarify first:'); @@ -482,3 +1043,111 @@ export function formatRouteSpec(spec: RouteSpec): string { return lines.join('\n'); } + +function quoteCliArg(value: string): string { + return `"${value.replace(/(["\\$`])/g, '\\$1')}"`; +} + +function formatChoiceConfidence(confidence: number): string { + return `${Math.round(confidence * 100)}%`; +} + +const GENERIC_SKILL_TOKENS = new Set([ + 'agent', + 'code', + 'coding', + 'command', + 'create', + 'debug', + 'docs', + 'guide', + 'impact', + 'ops', + 'plan', + 'pr', + 'plugin', + 'review', + 'router', + 'skill', + 'test', + 'testing', + 'workflow', +]); + +function normalizedMention(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/gi, ' ').replace(/\s+/g, ' ').trim(); +} + +function significantNameTokens(value: string): string[] { + return value + .split(/[^a-z0-9\u4e00-\u9fff]+/i) + .map(token => token.trim().toLowerCase()) + .filter(token => token.length >= 4 && !GENERIC_SKILL_TOKENS.has(token)); +} + +function queryMentionsCapability(query: string, cap: Pick): boolean { + const normalizedQuery = normalizedMention(query); + if (!normalizedQuery) return false; + const compactQuery = normalizedQuery.replace(/\s+/g, ''); + const names = unique([ + cap.name, + cap.id, + ...(cap.aliases ?? []), + ], item => item); + + for (const name of names) { + const normalizedName = normalizedMention(name); + const nameParts = normalizedName.split(/\s+/).filter(Boolean); + const onlyGenericName = nameParts.length === 1 && GENERIC_SKILL_TOKENS.has(nameParts[0]); + if (!onlyGenericName && normalizedName.length >= 4 && (normalizedQuery.includes(normalizedName) || compactQuery.includes(normalizedName.replace(/\s+/g, '')))) { + return true; + } + for (const token of significantNameTokens(name)) { + if (normalizedQuery.includes(token)) return true; + } + } + return false; +} + +function queryMentionsSkill(query: string, skill: RouteSkillRef): boolean { + return queryMentionsCapability(query, { id: skill.id, name: skill.name }); +} + +function primaryRouteSkills(spec: Pick): RouteSkillRef[] { + if (!spec.combo) return spec.skills; + const comboSkills = spec.skills.filter(skill => skill.origin === 'combo' || skill.reason?.startsWith('Combo ')); + const explicitMatchedSkills = spec.skills.filter(skill => + skill.available && + !(skill.origin === 'combo' || skill.reason?.startsWith('Combo ')) && + queryMentionsSkill(spec.query, skill)); + return comboSkills.length > 0 + ? unique([...comboSkills, ...explicitMatchedSkills], skill => skill.name) + : spec.skills; +} + +export function formatRouteSpecBrief(spec: RouteSpec): string { + const choices = [spec.choices.recommended, ...spec.choices.alternatives]; + const modelChoice = choices.find(choice => choice.kind === 'model'); + const councilChoice = choices.find(choice => choice.id === 'mode:council'); + const primarySkills = primaryRouteSkills(spec); + const availableSkillNames = primarySkills.filter(skill => skill.available).slice(0, 4).map(skill => skill.name); + const missingSkillNames = primarySkills.filter(skill => !skill.available).slice(0, 3).map(skill => skill.name); + const mode = `${spec.mode}${spec.executionMode ? `/${spec.executionMode}` : ''}`; + const detailParts: string[] = []; + if (modelChoice) detailParts.push(`Model: ${modelChoice.label} (${formatChoiceConfidence(modelChoice.confidence)})`); + if (councilChoice) detailParts.push(`Council: ${councilChoice.label} (${formatChoiceConfidence(councilChoice.confidence)})`); + if (availableSkillNames.length > 0) detailParts.push(`Use: ${availableSkillNames.join(', ')}`); + if (missingSkillNames.length > 0) detailParts.push(`Missing: ${missingSkillNames.join(', ')} (generic prompt)`); + if (spec.warnings.length > 0) detailParts.push(`Warnings: ${spec.warnings.length}`); + if (spec.unlockWarnings?.length) detailParts.push(`Unlock: ${spec.unlockWarnings.length}`); + if (spec.clarificationQuestions?.length) { + detailParts.push(`Clarify: ${spec.clarificationQuestions[0]}`); + } + + const lines = [ + `Route: ${spec.combo ?? spec.mode} | Intent: ${spec.intent} | Mode: ${mode} | Recommended: ${spec.choices.recommended.id} (${formatChoiceConfidence(spec.choices.recommended.confidence)})`, + ]; + if (detailParts.length > 0) lines.push(detailParts.join(' | ')); + lines.push(`Prompt: lazybrain prompt ${quoteCliArg(spec.query)} --target ${spec.target} --copy`); + return lines.join('\n'); +} diff --git a/src/privacy/prompts.ts b/src/privacy/prompts.ts new file mode 100644 index 0000000..42ad5ca --- /dev/null +++ b/src/privacy/prompts.ts @@ -0,0 +1,31 @@ +import { createHash } from 'node:crypto'; + +const REDACTED_PROMPT_PREFIX = '[redacted-prompt:'; + +export function hashPrompt(prompt: string): string { + return createHash('sha1').update(prompt).digest('hex').slice(0, 16); +} + +export function redactedPromptLabel(hash: string): string { + return `${REDACTED_PROMPT_PREFIX}${hash}]`; +} + +export function isRedactedPromptLabel(value: string): boolean { + return value.startsWith(REDACTED_PROMPT_PREFIX) && value.endsWith(']'); +} + +export function redactPromptForStorage(prompt: string): { query: string; queryHash: string } { + if (isRedactedPromptLabel(prompt)) { + return { query: prompt, queryHash: prompt.slice(REDACTED_PROMPT_PREFIX.length, -1) }; + } + const queryHash = hashPrompt(prompt); + return { query: redactedPromptLabel(queryHash), queryHash }; +} + +export function sanitizePromptRecord(entry: T): T & { query?: string; queryHash?: string } { + if (typeof entry.query !== 'string') return entry as T & { query?: string; queryHash?: string }; + const redacted = typeof entry.queryHash === 'string' + ? { query: redactedPromptLabel(entry.queryHash), queryHash: entry.queryHash } + : redactPromptForStorage(entry.query); + return { ...entry, ...redacted }; +} diff --git a/src/runtime/jobs.ts b/src/runtime/jobs.ts new file mode 100644 index 0000000..c853e83 --- /dev/null +++ b/src/runtime/jobs.ts @@ -0,0 +1,207 @@ +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { JOBS_DIR, JOBS_LATEST_PATH } from '../constants.js'; + +export type JobKind = 'scan' | 'compile' | 'embedding' | 'doctor' | 'gitnexus' | 'cache'; +export type JobState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'stale'; + +export interface BackendJob { + id: string; + kind: JobKind; + state: JobState; + progress?: string; + startedAt?: string; + updatedAt: string; + finishedAt?: string; + exitCode?: number | null; + error?: string; + recentLog: string[]; + result?: unknown; +} + +interface LatestJobsIndex { + updatedAt: string; + ids: string[]; + latestByKind: Partial>; +} + +interface LocalActiveJob { + kind: JobKind; + cancel: () => boolean; +} + +const localActiveJobs = new Map(); +const TERMINAL_STATES = new Set(['succeeded', 'failed', 'cancelled', 'stale']); + +function nowIso(): string { + return new Date().toISOString(); +} + +function latestPathFor(jobsDir: string): string { + return jobsDir === JOBS_DIR ? JOBS_LATEST_PATH : join(jobsDir, 'latest.json'); +} + +function ensureJobsDir(jobsDir = JOBS_DIR): void { + mkdirSync(jobsDir, { recursive: true }); +} + +function jobPath(id: string, jobsDir = JOBS_DIR): string { + return join(jobsDir, `${id}.json`); +} + +function safeJobId(id: string): boolean { + return /^[a-z][a-z0-9-]{2,120}$/i.test(id); +} + +function readLatestIndex(jobsDir = JOBS_DIR): LatestJobsIndex { + const path = latestPathFor(jobsDir); + if (!existsSync(path)) return { updatedAt: nowIso(), ids: [], latestByKind: {} }; + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial; + return { + updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : nowIso(), + ids: Array.isArray(parsed.ids) ? parsed.ids.filter((id): id is string => typeof id === 'string') : [], + latestByKind: parsed.latestByKind && typeof parsed.latestByKind === 'object' ? parsed.latestByKind : {}, + }; + } catch { + return { updatedAt: nowIso(), ids: [], latestByKind: {} }; + } +} + +function writeLatestIndex(index: LatestJobsIndex, jobsDir = JOBS_DIR): void { + ensureJobsDir(jobsDir); + writeFileSync(latestPathFor(jobsDir), JSON.stringify(index, null, 2), 'utf-8'); +} + +function rememberJob(job: BackendJob, jobsDir = JOBS_DIR): void { + const index = readLatestIndex(jobsDir); + index.ids = [job.id, ...index.ids.filter(id => id !== job.id)].slice(0, 200); + index.latestByKind[job.kind] = job.id; + index.updatedAt = nowIso(); + writeLatestIndex(index, jobsDir); +} + +export function createJob( + kind: JobKind, + init: Partial> = {}, + jobsDir = JOBS_DIR, +): BackendJob { + ensureJobsDir(jobsDir); + const timestamp = Date.now().toString(36); + const id = `${kind}-${timestamp}-${randomUUID().slice(0, 8)}`; + const updatedAt = nowIso(); + const job: BackendJob = { + id, + kind, + state: init.state ?? 'queued', + progress: init.progress, + startedAt: init.startedAt, + updatedAt, + finishedAt: init.finishedAt, + exitCode: init.exitCode, + error: init.error, + recentLog: [], + result: init.result, + }; + writeFileSync(jobPath(id, jobsDir), JSON.stringify(job, null, 2), 'utf-8'); + rememberJob(job, jobsDir); + return job; +} + +export function getJob(id: string, jobsDir = JOBS_DIR): BackendJob | null { + if (!safeJobId(id)) return null; + const path = jobPath(id, jobsDir); + if (!existsSync(path)) return null; + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as BackendJob; + if (!parsed || parsed.id !== id || typeof parsed.kind !== 'string') return null; + return { + ...parsed, + recentLog: Array.isArray(parsed.recentLog) ? parsed.recentLog.filter((line): line is string => typeof line === 'string') : [], + }; + } catch { + return null; + } +} + +export function updateJob( + id: string, + patch: Partial>, + jobsDir = JOBS_DIR, +): BackendJob | null { + const existing = getJob(id, jobsDir); + if (!existing) return null; + const updatedAt = nowIso(); + const state = patch.state ?? existing.state; + const next: BackendJob = { + ...existing, + ...patch, + updatedAt, + finishedAt: patch.finishedAt ?? (TERMINAL_STATES.has(state) ? existing.finishedAt ?? updatedAt : existing.finishedAt), + recentLog: patch.recentLog ?? existing.recentLog, + }; + writeFileSync(jobPath(id, jobsDir), JSON.stringify(next, null, 2), 'utf-8'); + rememberJob(next, jobsDir); + return next; +} + +export function appendJobLog(id: string, lines: string[], jobsDir = JOBS_DIR): BackendJob | null { + const clean = lines.map(line => line.trim()).filter(Boolean); + if (clean.length === 0) return getJob(id, jobsDir); + const job = getJob(id, jobsDir); + if (!job) return null; + return updateJob(id, { recentLog: [...job.recentLog, ...clean].slice(-100) }, jobsDir); +} + +export function listJobs(options: { limit?: number; jobsDir?: string } = {}): BackendJob[] { + const jobsDir = options.jobsDir ?? JOBS_DIR; + if (!existsSync(jobsDir)) return []; + const jobs = readdirSync(jobsDir) + .filter(name => name.endsWith('.json') && name !== 'latest.json') + .map(name => getJob(name.slice(0, -5), jobsDir)) + .filter((job): job is BackendJob => Boolean(job)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return jobs.slice(0, Math.max(1, Math.min(options.limit ?? 20, 100))); +} + +export function listActiveJobs(options: { kind?: JobKind; localOnly?: boolean; jobsDir?: string } = {}): BackendJob[] { + return listJobs({ limit: 100, jobsDir: options.jobsDir }).filter((job) => { + if (options.kind && job.kind !== options.kind) return false; + if (job.state !== 'queued' && job.state !== 'running') return false; + return options.localOnly ? localActiveJobs.has(job.id) : true; + }); +} + +export function hasLocalActiveJob(kind?: JobKind): boolean { + for (const active of localActiveJobs.values()) { + if (!kind || active.kind === kind) return true; + } + return false; +} + +export function registerJobCanceller(id: string, kind: JobKind, cancel: () => boolean): void { + localActiveJobs.set(id, { kind, cancel }); +} + +export function clearJobCanceller(id: string): void { + localActiveJobs.delete(id); +} + +export function cancelJob(id: string): { ok: boolean; job: BackendJob | null; error?: string } { + const job = getJob(id); + if (!job) return { ok: false, job: null, error: `Job not found: ${id}` }; + if (TERMINAL_STATES.has(job.state)) return { ok: false, job, error: `Job is already ${job.state}` }; + const active = localActiveJobs.get(id); + if (!active) { + const stale = updateJob(id, { state: 'stale', error: 'no active local process' }); + return { ok: false, job: stale, error: 'Job has no active local process' }; + } + const cancelled = active.cancel(); + clearJobCanceller(id); + const updated = updateJob(id, { + state: cancelled ? 'cancelled' : 'failed', + error: cancelled ? undefined : 'cancel handler failed', + }); + return { ok: cancelled, job: updated, error: cancelled ? undefined : 'Cancel handler failed' }; +} diff --git a/src/runtime/status.ts b/src/runtime/status.ts new file mode 100644 index 0000000..956fedf --- /dev/null +++ b/src/runtime/status.ts @@ -0,0 +1,20 @@ +import { dirname } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { STATUS_PATH } from '../constants.js'; + +export function readRuntimeStatus(path = STATUS_PATH): Record { + if (!existsSync(path)) return {}; + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +} + +export function mergeRuntimeStatus(patch: Record, path = STATUS_PATH): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ ...readRuntimeStatus(path), ...patch, updatedAt: Date.now() })); +} diff --git a/src/scanner/metadata.ts b/src/scanner/metadata.ts new file mode 100644 index 0000000..0e903ba --- /dev/null +++ b/src/scanner/metadata.ts @@ -0,0 +1,44 @@ +import type { CapabilitySideEffect } from '../types.js'; + +const SIDE_EFFECTS: readonly CapabilitySideEffect[] = [ + 'reads_files', + 'writes_files', + 'executes_commands', + 'network', + 'changes_config', + 'installs_hooks', + 'publishes', + 'destructive', + 'unknown', +]; + +export function parseCapabilityMetadata(frontmatter: Record): { + provider?: string; + conflictGroup?: string; + sideEffects?: CapabilitySideEffect[]; +} { + const provider = typeof frontmatter.provider === 'string' && frontmatter.provider.trim() + ? frontmatter.provider.trim() + : undefined; + const conflictGroup = typeof frontmatter.conflictGroup === 'string' && frontmatter.conflictGroup.trim() + ? frontmatter.conflictGroup.trim() + : undefined; + + let sideEffects: CapabilitySideEffect[] | undefined; + const rawSideEffects = frontmatter.sideEffects ?? frontmatter.side_effects; + if (Array.isArray(rawSideEffects)) { + sideEffects = rawSideEffects.filter((item): item is CapabilitySideEffect => + typeof item === 'string' && SIDE_EFFECTS.includes(item as CapabilitySideEffect)); + } else if (typeof rawSideEffects === 'string') { + sideEffects = rawSideEffects + .split(',') + .map(item => item.trim()) + .filter((item): item is CapabilitySideEffect => SIDE_EFFECTS.includes(item as CapabilitySideEffect)); + } + + return { + ...(provider ? { provider } : {}), + ...(conflictGroup ? { conflictGroup } : {}), + ...(sideEffects?.length ? { sideEffects } : {}), + }; +} diff --git a/src/scanner/parsers/agent-parser.ts b/src/scanner/parsers/agent-parser.ts index fd5fc0a..e2cea4b 100644 --- a/src/scanner/parsers/agent-parser.ts +++ b/src/scanner/parsers/agent-parser.ts @@ -6,6 +6,7 @@ import type { RawCapability } from '../../types.js'; import { parseFrontmatter } from '../../utils/yaml.js'; import { inferPlatformFromPath, inferSinglePlatformFromPath } from '../../constants.js'; import { inferOrigin } from '../origin.js'; +import { parseCapabilityMetadata } from '../metadata.js'; /** * Extract first non-heading paragraph from body. @@ -55,6 +56,7 @@ export function parseAgent(filePath: string, content: string): RawCapability | n name, description, origin, + ...parseCapabilityMetadata(frontmatter), filePath, compatibility: inferPlatformFromPath(filePath), platform: inferSinglePlatformFromPath(filePath), diff --git a/src/scanner/parsers/command-parser.ts b/src/scanner/parsers/command-parser.ts index 6e296d6..a37c468 100644 --- a/src/scanner/parsers/command-parser.ts +++ b/src/scanner/parsers/command-parser.ts @@ -6,6 +6,7 @@ import type { RawCapability } from '../../types.js'; import { parseFrontmatter } from '../../utils/yaml.js'; import { inferPlatformFromPath, inferSinglePlatformFromPath } from '../../constants.js'; import { inferOrigin } from '../origin.js'; +import { parseCapabilityMetadata } from '../metadata.js'; /** * Extract first non-heading paragraph from body. @@ -53,6 +54,7 @@ export function parseCommand(filePath: string, content: string): RawCapability | filePath, typeof frontmatter.origin === 'string' && frontmatter.origin ? frontmatter.origin : undefined, ), + ...parseCapabilityMetadata(frontmatter), filePath, compatibility: inferPlatformFromPath(filePath), platform: inferSinglePlatformFromPath(filePath), diff --git a/src/scanner/parsers/skill-parser.ts b/src/scanner/parsers/skill-parser.ts index 2159e80..e0c03ee 100644 --- a/src/scanner/parsers/skill-parser.ts +++ b/src/scanner/parsers/skill-parser.ts @@ -6,6 +6,7 @@ import type { RawCapability } from '../../types.js'; import { parseFrontmatter } from '../../utils/yaml.js'; import { inferPlatformFromPath, inferSinglePlatformFromPath } from '../../constants.js'; import { inferOrigin } from '../origin.js'; +import { parseCapabilityMetadata } from '../metadata.js'; import { parseSkillSchema } from '../../schema/skill-schema.js'; /** @@ -87,6 +88,7 @@ export function parseSkill(filePath: string, content: string): RawCapability | n name, description, origin, + ...parseCapabilityMetadata(frontmatter), filePath, triggers, compatibility: inferPlatformFromPath(filePath), diff --git a/src/scanner/scanner.ts b/src/scanner/scanner.ts index ebbd964..36e3fb7 100644 --- a/src/scanner/scanner.ts +++ b/src/scanner/scanner.ts @@ -97,6 +97,10 @@ function safeReadFile(filePath: string): string | null { } } +function isSkillRootPath(path: string): boolean { + return path.includes('/skills') || path.includes('/skills-disabled') || basename(path) === '.skillshub'; +} + export function scan(options?: ScanOptions): ScanResult { const paths = [...getDefaultScanPaths(options?.platforms), ...(options?.extraPaths ?? [])]; const capabilities: RawCapability[] = []; @@ -109,7 +113,7 @@ export function scan(options?: ScanOptions): ScanResult { if (!existsSync(path) || !isDirectory(path)) continue; try { - if (path.includes('/skills') || path.includes('/skills-disabled')) { + if (isSkillRootPath(path)) { const skillFiles = findSkillFiles(path); for (const filePath of skillFiles) { scannedFiles++; diff --git a/src/server/liveness.ts b/src/server/liveness.ts new file mode 100644 index 0000000..7e5b79e --- /dev/null +++ b/src/server/liveness.ts @@ -0,0 +1,71 @@ +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { LAZYBRAIN_DIR } from '../constants.js'; + +export const DEFAULT_PORT = 18450; +export const SERVER_RUNNING_FLAG = join(LAZYBRAIN_DIR, '.server-running'); +export const SERVER_PID_FILE = join(LAZYBRAIN_DIR, 'server.pid'); + +export interface ServerLivenessPaths { + runningFlagPath?: string; + pidFilePath?: string; +} + +export interface ServerRuntimeState { + running: boolean; + port: number; + pid: number | null; +} + +function runningFlagPath(paths?: ServerLivenessPaths): string { + return paths?.runningFlagPath ?? SERVER_RUNNING_FLAG; +} + +function pidFilePath(paths?: ServerLivenessPaths): string { + return paths?.pidFilePath ?? SERVER_PID_FILE; +} + +function cleanupServerMarkers(paths?: ServerLivenessPaths): void { + try { unlinkSync(runningFlagPath(paths)); } catch {} + try { unlinkSync(pidFilePath(paths)); } catch {} +} + +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +export function getServerPort(paths?: ServerLivenessPaths): number { + const path = runningFlagPath(paths); + if (!existsSync(path)) return DEFAULT_PORT; + const raw = readFileSync(path, 'utf-8').trim(); + const n = parseInt(raw, 10); + return Number.isFinite(n) ? n : DEFAULT_PORT; +} + +export function getServerPid(paths?: ServerLivenessPaths): number | null { + const path = pidFilePath(paths); + if (!existsSync(path)) return null; + const raw = readFileSync(path, 'utf-8').trim(); + const n = parseInt(raw, 10); + return Number.isFinite(n) ? n : null; +} + +export function getServerRuntimeState(paths?: ServerLivenessPaths): ServerRuntimeState { + const hasFlag = existsSync(runningFlagPath(paths)); + const pid = getServerPid(paths); + const port = getServerPort(paths); + const running = Boolean(hasFlag && pid && pidAlive(pid)); + if (!running && (hasFlag || pid !== null)) { + cleanupServerMarkers(paths); + } + return { running, port, pid: running ? pid : null }; +} + +export function isServerRunning(paths?: ServerLivenessPaths): boolean { + return getServerRuntimeState(paths).running; +} diff --git a/src/server/router.ts b/src/server/router.ts index ed9c752..1f4a488 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -6,10 +6,11 @@ */ import type * as http from 'node:http'; -import { readdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { readdirSync, existsSync, readFileSync } from 'node:fs'; import { spawn } from 'node:child_process'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; +import { fileURLToPath } from 'node:url'; import type { Graph } from '../graph/graph.js'; import type { Platform, RouteTarget, UserConfig } from '../types.js'; import { buildGraphView, formatGraphMermaid } from '../graph/graph-view.js'; @@ -24,21 +25,88 @@ import { evaluateLab } from '../lab/evaluator.js'; import { scanAgentInventory } from '../lab/agent-inventory.js'; import { UI_HTML } from '../ui/html.js'; import { buildStatusReport } from './status.js'; +import { redactConfig } from '../config/redaction.js'; import { loadConfig, saveConfig } from '../config/config.js'; +import { + CONFIG_ALLOWED_KEYS, + validateConfigUpdate, +} from '../config/schema.js'; import { getHookRuntimeSnapshot, getHookRuntimeStats } from '../hook/runtime.js'; import { runApiTests, type ApiTestTarget } from '../health/api-test.js'; import { getEmbeddingCacheStatus } from '../embeddings/cache.js'; import { rebuildEmbeddingCache } from '../embeddings/rebuild.js'; -import { EMBEDDINGS_INDEX_PATH, GRAPH_PATH, LAZYBRAIN_DIR, ROUTE_EVENTS_PATH } from '../constants.js'; +import { EMBEDDINGS_INDEX_PATH, GRAPH_PATH, LAZYBRAIN_DIR } from '../constants.js'; import { buildRouteSpec, isRouteTarget } from '../orchestrator/route.js'; import { loadRecentHistory } from '../history/history.js'; import { loadProfile } from '../history/profile.js'; -import { recordRouteSpec } from '../orchestrator/route-events.js'; +import { readRecentRouteEvents } from '../orchestrator/route-events.js'; +import { mergeRuntimeStatus } from '../runtime/status.js'; +import { getGitNexusStatus } from '../integrations/gitnexus.js'; +import { cloneHttpRoutes, defineHttpRoutes } from './routes.js'; +import { + appendJobLog, + clearJobCanceller, + createJob, + getJob, + listActiveJobs, + listJobs, + registerJobCanceller, + updateJob, +} from '../runtime/jobs.js'; // ─── Rate Limiter ──────────────────────────────────────────────────────────── const rateLimitMap = new Map(); const RATE_LIMIT = 100; // per second per IP +const ROUTER_DIR = dirname(fileURLToPath(import.meta.url)); +const LAZYBRAIN_CLI_CANDIDATES = [ + join(ROUTER_DIR, 'bin', 'lazybrain.js'), + join(ROUTER_DIR, '..', 'dist', 'bin', 'lazybrain.js'), + join(ROUTER_DIR, '..', '..', 'dist', 'bin', 'lazybrain.js'), +]; + +export const HTTP_ROUTES = defineHttpRoutes((router) => { + router.get('/', 'handleUiPage', 'Serve the local Workbench UI.', 'ui'); + router.get('/ui', 'handleUiPage', 'Serve the local Workbench UI.', 'ui'); + router.get('/health', 'handleHealth', 'Return liveness and graph size.'); + router.get('/api/health', 'handleHealth', 'Return liveness and graph size.', 'api'); + router.get('/api/status', 'handleStatus', 'Return product, runtime, hook, graph, embedding, and GitNexus status.', 'api'); + router.get('/api/diagnostics', 'handleDiagnostics', 'Return privacy-preserving local diagnostics.', 'api'); + router.get('/api/routes', 'handleRoutesMetadata', 'Return the active HTTP route registry.', 'api'); + router.post('/api/route', 'handleRoute', 'Build a RouteSpec for a user task.', 'api'); + router.post('/api/compile', 'handleCompileStart', 'Start a compile job.', 'api'); + router.get('/api/compile/status', 'handleCompileStatus', 'Return compile job status.', 'api'); + router.get('/api/embedding/discover', 'handleEmbeddingDiscover', 'Probe local embedding services.', 'api'); + router.get('/api/embeddings/status', 'handleEmbeddingStatus', 'Return embedding cache status.', 'api'); + router.post('/api/embeddings/rebuild', 'handleEmbeddingRebuild', 'Start an embedding rebuild job.', 'api'); + router.get('/api/config', 'handleGetConfig', 'Return redacted local config.', 'api'); + router.post('/api/config', 'handleUpdateConfig', 'Persist validated local config.', 'api'); + router.post('/api/test', 'handleApiTest', 'Run explicitly requested local API checks.', 'api'); + router.get('/api/search', 'handleSearch', 'Search the local capability graph.', 'api'); + router.post('/api/match', 'handleMatch', 'Return capability matches for a query.', 'api'); + router.post('/api/team', 'handleTeam', 'Recommend a capability team for a query.', 'api'); + router.get('/api/stats', 'handleStats', 'Return graph statistics.', 'api'); + router.get('/api/graph', 'handleGraphView', 'Return graph view data.', 'api'); + router.post('/route', 'handleRoute', 'Legacy alias for route planning.'); + router.post('/match', 'handleMatch', 'Legacy alias for capability matching.'); + router.post('/team', 'handleTeam', 'Legacy alias for team recommendation.'); + router.get('/stats', 'handleStats', 'Legacy alias for graph statistics.'); + router.get('/graph', 'handleGraphView', 'Legacy alias for graph view data.'); + router.get('/dups', 'handleDups', 'Return duplicate capability candidates.'); + router.get('/search', 'handleSearch', 'Legacy alias for capability search.'); + router.get('/capability/:id', 'handleCapability', 'Return one capability card.'); + router.get('/lab', 'handleLabPage', 'Serve the route evaluation lab.', 'lab'); + router.get('/lab/fixtures', 'handleLabFixtures', 'Return built-in lab fixtures.', 'lab'); + router.get('/api/lab/fixtures', 'handleLabFixtures', 'Return built-in lab fixtures.', 'api'); + router.get('/lab/agents', 'handleLabAgents', 'Return sanitized agent inventory metadata.', 'lab'); + router.get('/api/lab/agents', 'handleLabAgents', 'Return sanitized agent inventory metadata.', 'api'); + router.post('/lab/evaluate', 'handleLabEvaluate', 'Evaluate lab queries.', 'lab'); + router.post('/api/lab/evaluate', 'handleLabEvaluate', 'Evaluate lab queries.', 'api'); + router.post('/reload', 'handleReload', 'Reload the local graph.'); + router.get('/report/summary', 'handleReportSummary', 'Return local recommendation summary.', 'report'); + router.get('/report/sessions', 'handleReportSessions', 'Return local session report index.', 'report'); + router.get('/report/session/:id', 'handleReportSession', 'Return one local session report.', 'report'); +}); function isRateLimited(ip: string): boolean { const now = Date.now(); @@ -74,6 +142,13 @@ function err(res: http.ServerResponse, code: number, message: string): void { json(res, code, { error: message, code }); } +function resolveLazyBrainCliPath(): string | null { + for (const path of LAZYBRAIN_CLI_CANDIDATES) { + if (existsSync(path)) return path; + } + return null; +} + async function readBody(req: http.IncomingMessage, maxBytes = 64 * 1024): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; @@ -92,6 +167,17 @@ async function readBody(req: http.IncomingMessage, maxBytes = 64 * 1024): Promis }); } +function isLocalRequest(req: http.IncomingMessage): boolean { + const address = req.socket.remoteAddress ?? ''; + const localAddress = address === '' || + address === '127.0.0.1' || + address === '::1' || + address === '::ffff:127.0.0.1'; + if (!localAddress) return false; + const origin = req.headers.origin ?? req.headers.referer ?? ''; + return !origin || origin.startsWith('http://127.0.0.1') || origin.startsWith('http://localhost'); +} + // ─── Route Handlers ────────────────────────────────────────────────────────── async function handleMatch( @@ -141,7 +227,6 @@ async function handleRoute( profile: loadProfile() ?? undefined, target: body.target ?? 'generic', }); - recordRouteSpec(result, 'api'); json(res, 200, result); } @@ -305,6 +390,13 @@ function handleStatus( json(res, 200, buildStatusReport(graph, config)); } +function handleRoutesMetadata( + _req: http.IncomingMessage, + res: http.ServerResponse, +): void { + json(res, 200, { routes: cloneHttpRoutes(HTTP_ROUTES) }); +} + async function handleApiTest( req: http.IncomingMessage, res: http.ServerResponse, @@ -338,17 +430,70 @@ async function handleEmbeddingRebuild( graph: Graph, config: UserConfig, ): Promise { - let body: { confirm?: string }; + let body: { confirm?: string | boolean; force?: boolean }; try { - body = JSON.parse(await readBody(req)) as { confirm?: string }; + body = JSON.parse(await readBody(req)) as { confirm?: string | boolean; force?: boolean }; } catch { return err(res, 400, 'Invalid JSON body'); } - if (body.confirm !== 'rebuild') { + if (body.confirm !== 'rebuild' && body.confirm !== true) { return err(res, 400, 'Embedding rebuild requires {"confirm":"rebuild"}.'); } - const result = await rebuildEmbeddingCache(graph.getAllNodes(), config); - json(res, result.ok ? 200 : 500, result); + const result = startEmbeddingJob(graph, config, body.force === true); + json(res, result.status, result.body); +} + +function startEmbeddingJob( + graph: Graph, + config: UserConfig, + force: boolean, +): { status: number; body: Record } { + const active = listActiveJobs({ kind: 'embedding', localOnly: true })[0]; + if (active) return { status: 409, body: { ok: false, error: 'Embedding rebuild is already running', jobId: active.id } }; + + const job = createJob('embedding', { progress: force ? 'queued full rebuild' : 'queued incremental rebuild' }); + let cancelled = false; + registerJobCanceller(job.id, 'embedding', () => { + cancelled = true; + return true; + }); + updateJob(job.id, { + state: 'running', + startedAt: new Date().toISOString(), + progress: force ? 'full rebuild running' : 'incremental rebuild running', + }); + mergeRuntimeStatus({ state: 'embedding', progress: force ? 'full' : 'incremental' }); + + void (async () => { + try { + appendJobLog(job.id, [`embedding rebuild started (${force ? 'full' : 'incremental'})`]); + const result = await rebuildEmbeddingCache(graph.getAllNodes(), config, { force }); + if (cancelled || getJob(job.id)?.state === 'cancelled') return; + appendJobLog(job.id, [result.ok ? `embedding rebuild indexed ${result.indexed}` : `embedding rebuild failed: ${result.error ?? 'unknown error'}`]); + mergeRuntimeStatus({ + state: 'idle', + lastEmbeddingAt: Date.now(), + lastEmbeddingResult: result.ok ? 'ok' : 'failed', + }); + updateJob(job.id, { + state: result.ok ? 'succeeded' : 'failed', + progress: result.ok ? 'completed' : 'failed', + exitCode: result.ok ? 0 : 1, + error: result.ok ? undefined : result.error ?? 'embedding rebuild failed', + result, + }); + } catch (error) { + if (cancelled || getJob(job.id)?.state === 'cancelled') return; + const message = error instanceof Error ? error.message : String(error); + mergeRuntimeStatus({ state: 'idle', lastEmbeddingAt: Date.now(), lastEmbeddingResult: 'failed' }); + appendJobLog(job.id, [`embedding rebuild failed: ${message}`]); + updateJob(job.id, { state: 'failed', progress: 'failed', exitCode: 1, error: message }); + } finally { + clearJobCanceller(job.id); + } + })(); + + return { status: 200, body: { ok: true, jobId: job.id } }; } function handleLabPage( @@ -470,18 +615,19 @@ function handleReportSession( // ─── Config /api/config ─────────────────────────────────────────────────────── -const CONFIG_ALLOWED_KEYS = new Set([ - 'compileApiBase', 'compileApiKey', 'compileModel', - 'embeddingApiBase', 'embeddingApiKey', 'embeddingModel', 'embeddingSource', - 'secretaryApiBase', 'secretaryApiKey', 'secretaryModel', - 'engine', 'strategy', 'mode', 'autoThreshold', 'language', - 'compileSystemPrompt', 'compileTagPrompt', 'compileRelationPrompt', -]); +function handleGetConfig( + _req: http.IncomingMessage, + res: http.ServerResponse, + liveConfig: UserConfig, +): void { + json(res, 200, { ok: true, config: redactConfig({ ...loadConfig(), ...liveConfig }) }); +} -const VALID_ENGINES = new Set(['tag', 'semantic', 'hybrid', 'llm']); -const VALID_STRATEGIES = new Set(['always-main', 'optimal', 'ask']); -const VALID_MODES = new Set(['auto', 'select', 'ask']); -const VALID_LANGUAGES = new Set(['auto', 'en', 'zh']); +function configFieldError(message: string): Record { + const match = message.match(/(?:Unknown config key:|Invalid|^)(?:\s+config key)?\s*"?([A-Za-z][A-Za-z0-9]*)"?/); + const key = match?.[1]; + return key && CONFIG_ALLOWED_KEYS.has(key) ? { [key]: message } : { _global: message }; +} async function handleUpdateConfig( req: http.IncomingMessage, @@ -489,8 +635,7 @@ async function handleUpdateConfig( liveConfig: UserConfig, ): Promise { // Only accept requests from local origin (defense-in-depth) - const origin = req.headers.origin ?? req.headers.referer ?? ''; - if (origin && !origin.startsWith('http://127.0.0.1') && !origin.startsWith('http://localhost')) { + if (!isLocalRequest(req)) { return json(res, 403, { ok: false, error: 'Forbidden: config writes only allowed from localhost' }); } @@ -505,47 +650,19 @@ async function handleUpdateConfig( return json(res, 400, { ok: false, error: 'Body must be a JSON object' }); } - // Validate keys - for (const key of Object.keys(body)) { - if (!CONFIG_ALLOWED_KEYS.has(key)) { - return json(res, 400, { ok: false, error: `Unknown config key: ${key}` }); - } - } - - // Validate values - for (const [key, value] of Object.entries(body)) { - if (key === 'autoThreshold') { - if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) { - return json(res, 400, { ok: false, error: 'autoThreshold must be a finite number between 0 and 1' }); - } - } else if (key === 'engine') { - if (!VALID_ENGINES.has(value as string)) { - return json(res, 400, { ok: false, error: `Invalid engine. Must be one of: ${[...VALID_ENGINES].join(', ')}` }); - } - } else if (key === 'strategy') { - if (!VALID_STRATEGIES.has(value as string)) { - return json(res, 400, { ok: false, error: `Invalid strategy. Must be one of: ${[...VALID_STRATEGIES].join(', ')}` }); - } - } else if (key === 'mode') { - if (!VALID_MODES.has(value as string)) { - return json(res, 400, { ok: false, error: `Invalid mode. Must be one of: ${[...VALID_MODES].join(', ')}` }); - } - } else if (key === 'language') { - if (!VALID_LANGUAGES.has(value as string)) { - return json(res, 400, { ok: false, error: `Invalid language. Must be one of: ${[...VALID_LANGUAGES].join(', ')}` }); - } - } else if (typeof value !== 'string') { - return json(res, 400, { ok: false, error: `config key "${key}" must be a string` }); - } + const validation = validateConfigUpdate(body); + if (!validation.ok) { + return json(res, 400, { ok: false, error: validation.error, fieldErrors: configFieldError(validation.error) }); } try { + const { patch, ignoredKeys } = validation; const config = loadConfig(); - Object.assign(config, body); + Object.assign(config, patch); saveConfig(config); // Also update the live config so /api/status reflects changes immediately - Object.assign(liveConfig, body); - json(res, 200, { ok: true }); + Object.assign(liveConfig, patch); + json(res, 200, { ok: true, ignoredKeys }); } catch (error) { json(res, 500, { ok: false, @@ -559,75 +676,194 @@ async function handleUpdateConfig( let _compileProcess: ReturnType | null = null; let _compileLog: string[] = []; let _compilePhase = ''; +let _compileExitCode: number | null = null; +let _compileTimedOut = false; +let _compileJobId: string | null = null; + +type CompileJobOptions = { + scanFirst: boolean; + withRelations?: boolean; + forceRelations?: boolean; +}; + +function explicitFlag(url: URL, ...keys: string[]): boolean { + return keys.some(key => { + const value = url.searchParams.get(key); + if (value === null) return false; + return value === '' || value === '1' || value === 'true' || value === 'yes'; + }); +} -function handleCompileStart( - _req: http.IncomingMessage, - res: http.ServerResponse, - config: UserConfig, -): void { +export function buildCompileArgs(options: Pick): string[] { + const args = ['compile']; + if (options.withRelations) args.push('--with-relations'); + if (options.forceRelations) args.push('--force-relations'); + return args; +} + +function startCompileJob( + onReload: () => void, + options: CompileJobOptions, +): { status: number; body: Record } { if (_compileProcess && _compileProcess.exitCode === null) { - return json(res, 409, { ok: false, error: 'Compilation is already running' }); + return { status: 409, body: { ok: false, error: 'Compilation is already running', jobId: _compileJobId } }; } _compileLog = []; _compilePhase = 'starting'; + _compileExitCode = null; + _compileTimedOut = false; - // Use the CLI's compile command - it already handles progress display - const args = ['compile']; - if (config.compileApiBase && config.compileApiKey) { - args.push('--with-relations'); - } + const compileArgs = buildCompileArgs(options); try { - const COMPILE_TIMEOUT_MS = 5 * 60 * 1000; - const child = spawn(process.execPath, [join(LAZYBRAIN_DIR, '..', '..', 'dist', 'bin', 'lazybrain.js'), ...args], { - cwd: process.cwd(), - env: { ...process.env, FORCE_COLOR: '0' }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - child.stdout.on('data', (data: Buffer) => { - const lines = data.toString().split('\n').filter(Boolean); - _compileLog.push(...lines); - // Keep only last 100 lines - if (_compileLog.length > 100) _compileLog = _compileLog.slice(-100); - for (const line of lines) { - if (line.includes('Phase 1')) _compilePhase = 'Phase 1/2: 标筟生成䞭...'; - if (line.includes('Phase 2')) _compilePhase = 'Phase 2/2: 关系掚理䞭...'; - if (line.includes('complete') || line.includes('Graph saved')) _compilePhase = '完成'; - } - }); - - child.stderr.on('data', (data: Buffer) => { - _compileLog.push('[err] ' + data.toString().trim()); - }); - - const _compileTimer = setTimeout(() => { if (child.exitCode === null) { child.kill(); _compilePhase = 'timeout'; _compileProcess = null; } }, COMPILE_TIMEOUT_MS); - child.on('close', (code) => { clearTimeout(_compileTimer); - _compilePhase = code === 0 ? 'completed' : 'failed'; - _compileProcess = null; + const COMPILE_TIMEOUT_MS = parseInt(process.env.LAZYBRAIN_COMPILE_TIMEOUT || '1200000', 10); // default 20 min + const cliPath = resolveLazyBrainCliPath(); + if (!cliPath) { + return { status: 500, body: { ok: false, error: 'LazyBrain CLI build not found. Run `npm run build` first.' } }; + } + + const job = createJob('compile', { progress: options.scanFirst ? 'queued scan' : options.withRelations ? 'queued relation compile' : 'queued compile' }); + _compileJobId = job.id; + let activeChild: ReturnType | null = null; + registerJobCanceller(job.id, 'compile', () => { + if (activeChild && activeChild.exitCode === null) return activeChild.kill(); + return false; }); - - _compileProcess = child; - json(res, 200, { ok: true, phase: _compilePhase }); + + const startTask = (taskArgs: string[], kind: 'scan' | 'compile', onSuccess?: () => void): void => { + _compilePhase = kind === 'scan' ? 'scanning' : 'starting'; + mergeRuntimeStatus({ state: kind === 'scan' ? 'scanning' : 'compiling', progress: _compilePhase }); + updateJob(job.id, { + state: 'running', + startedAt: getJob(job.id)?.startedAt ?? new Date().toISOString(), + progress: _compilePhase, + }); + const child = spawn(process.execPath, [cliPath, ...taskArgs], { + cwd: process.cwd(), + env: { ...process.env, FORCE_COLOR: '0' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + activeChild = child; + _compileProcess = child; + + child.stdout.on('data', (data: Buffer) => { + const lines = data.toString().split('\n').filter(Boolean); + _compileLog.push(...lines); + if (_compileLog.length > 100) _compileLog = _compileLog.slice(-100); + appendJobLog(job.id, lines); + for (const line of lines) { + if (kind === 'scan') { + if (line.includes('Scan complete')) _compilePhase = 'scan completed'; + } else { + if (line.includes('Phase 1')) _compilePhase = 'Phase 1/2: 标筟生成䞭...'; + if (line.includes('Phase 2')) _compilePhase = 'Phase 2/2: 关系掚理䞭...'; + if (line.includes('complete') || line.includes('Graph saved')) _compilePhase = '完成'; + } + } + updateJob(job.id, { progress: _compilePhase }); + }); + + child.stderr.on('data', (data: Buffer) => { + const line = '[err] ' + data.toString().trim(); + _compileLog.push(line); + if (_compileLog.length > 100) _compileLog = _compileLog.slice(-100); + appendJobLog(job.id, [line]); + }); + + child.on('error', (error) => { + _compileLog.push(`[err] ${error.message}`); + _compileExitCode = 1; + _compilePhase = 'failed'; + _compileProcess = null; + activeChild = null; + clearJobCanceller(job.id); + mergeRuntimeStatus({ state: 'idle', progress: 'failed' }); + updateJob(job.id, { state: 'failed', progress: 'failed', exitCode: 1, error: error.message }); + }); + + const compileTimer = setTimeout(() => { + if (child.exitCode === null) { + _compileTimedOut = true; + _compilePhase = 'timeout'; + child.kill(); + } + }, COMPILE_TIMEOUT_MS); + + child.on('close', (code, signal) => { + clearTimeout(compileTimer); + const exitCode = code ?? (_compileTimedOut ? 124 : signal ? 1 : null); + if (!_compileTimedOut && exitCode === 0 && onSuccess) { + onSuccess(); + return; + } + if (!_compileTimedOut && exitCode === 0 && kind === 'compile') { + try { + onReload(); + } catch (error) { + _compileLog.push(`[err] reload failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + _compileExitCode = exitCode; + _compilePhase = _compileTimedOut ? 'timeout' : exitCode === 0 ? 'completed' : 'failed'; + _compileProcess = null; + activeChild = null; + clearJobCanceller(job.id); + mergeRuntimeStatus({ + state: 'idle', + progress: _compilePhase, + ...(kind === 'compile' && exitCode === 0 ? { lastCompileAt: Date.now() } : {}), + }); + updateJob(job.id, { + state: exitCode === 0 ? 'succeeded' : 'failed', + progress: _compilePhase, + exitCode, + error: exitCode === 0 ? undefined : _compileTimedOut ? 'compile timed out' : `compile exited with code ${exitCode}`, + }); + }); + }; + + if (options.scanFirst) { + startTask(['scan'], 'scan', () => startTask(compileArgs, 'compile')); + } else { + startTask(compileArgs, 'compile'); + } + + return { status: 200, body: { ok: true, jobId: job.id, phase: _compilePhase, mode: options.withRelations ? 'relations' : 'fast' } }; } catch (err) { - json(res, 500, { ok: false, error: err instanceof Error ? err.message : 'Failed to start compile' }); + return { status: 500, body: { ok: false, error: err instanceof Error ? err.message : 'Failed to start compile' } }; } } +function handleCompileStart( + req: http.IncomingMessage, + res: http.ServerResponse, + onReload: () => void, +): void { + const url = new URL(req.url ?? '/', 'http://localhost'); + const result = startCompileJob(onReload, { + scanFirst: explicitFlag(url, 'scan'), + withRelations: explicitFlag(url, 'relations', 'withRelations'), + forceRelations: explicitFlag(url, 'forceRelations'), + }); + json(res, result.status, result.body); +} + function handleCompileStatus( _req: http.IncomingMessage, res: http.ServerResponse, ): void { const running = _compileProcess !== null && _compileProcess.exitCode === null; + const job = _compileJobId ? getJob(_compileJobId) : listJobs({ limit: 1 }).find(item => item.kind === 'compile') ?? null; json(res, 200, { + jobId: job?.id ?? null, + state: job?.state ?? (running ? 'running' : 'idle'), running, - phase: _compilePhase || (running ? 'running' : 'idle'), - recentLog: _compileLog.slice(-20), - exitCode: _compileProcess?.exitCode ?? null, + phase: job?.progress ?? (_compilePhase || (running ? 'running' : 'idle')), + recentLog: (job?.recentLog ?? _compileLog).slice(-20), + exitCode: running ? null : job?.exitCode ?? _compileExitCode, }); } - // ─── Diagnostics /api/diagnostics ──────────────────────────────────────────── function handleDiagnostics( @@ -635,22 +871,13 @@ function handleDiagnostics( res: http.ServerResponse, graph: Graph, config: UserConfig, + routeEventsPath?: string, ): void { // Hook runtime stats const runtime = getHookRuntimeSnapshot({ config }); const runtimeStats = getHookRuntimeStats(runtime); - // Recent events from route-events.jsonl (last 10 lines) - let recentEvents: unknown[] = []; - if (existsSync(ROUTE_EVENTS_PATH)) { - try { - const content = readFileSync(ROUTE_EVENTS_PATH, 'utf-8'); - const lines = content.trim().split('\n').filter(Boolean); - recentEvents = lines.slice(-10).map(line => { - try { return JSON.parse(line) as unknown; } catch { return line; } - }); - } catch {} - } + const recentEvents = readRecentRouteEvents({ limit: 10, path: routeEventsPath }); // Recent matches from last-match.json const lastMatchPath = join(LAZYBRAIN_DIR, 'last-match.json'); @@ -686,6 +913,7 @@ function handleDiagnostics( nodes: graph.getAllNodes().length, lastCompiled, }, + gitNexus: getGitNexusStatus(), embeddingStatus: embedding.state, }); } @@ -697,6 +925,7 @@ export interface RouterOptions { config: UserConfig; version: string; onReload: () => void; + routeEventsPath?: string; } @@ -790,11 +1019,14 @@ export function createRouter(opts: RouterOptions): http.RequestListener { if (method === 'GET' && pathname === '/api/status') { return handleStatus(req, res, graph, opts.config); } + if (method === 'GET' && pathname === '/api/routes') { + return handleRoutesMetadata(req, res); + } if (method === 'GET' && pathname === '/api/diagnostics') { - return handleDiagnostics(req, res, graph, opts.config); + return handleDiagnostics(req, res, graph, opts.config, opts.routeEventsPath); } if (method === 'POST' && pathname === '/api/compile') { - return handleCompileStart(req, res, opts.config); + return handleCompileStart(req, res, opts.onReload); } if (method === 'GET' && pathname === '/api/embedding/discover') { return handleEmbeddingDiscover(req, res); @@ -802,6 +1034,9 @@ export function createRouter(opts: RouterOptions): http.RequestListener { if (method === 'GET' && pathname === '/api/compile/status') { return handleCompileStatus(req, res); } + if (method === 'GET' && pathname === '/api/config') { + return handleGetConfig(req, res, opts.config); + } if (method === 'POST' && pathname === '/api/config') { return handleUpdateConfig(req, res, opts.config); } diff --git a/src/server/routes.ts b/src/server/routes.ts new file mode 100644 index 0000000..533d681 --- /dev/null +++ b/src/server/routes.ts @@ -0,0 +1,40 @@ +export type HttpRouteMethod = 'GET' | 'POST'; +export type HttpRouteSurface = 'api' | 'ui' | 'lab' | 'legacy' | 'report'; + +export interface HttpRouteDefinition { + method: HttpRouteMethod; + path: string; + handler: string; + surface: HttpRouteSurface; + description: string; + public: boolean; +} + +interface RouteRegistry { + get(path: string, handler: string, description: string, surface?: HttpRouteSurface): void; + post(path: string, handler: string, description: string, surface?: HttpRouteSurface): void; +} + +export function defineHttpRoutes(register: (router: RouteRegistry) => void): readonly HttpRouteDefinition[] { + const routes: HttpRouteDefinition[] = []; + const addHttpRoute = ( + method: HttpRouteMethod, + path: string, + handler: string, + description: string, + surface: HttpRouteSurface = path.startsWith('/api/') ? 'api' : 'legacy', + ): void => { + routes.push({ method, path, handler, surface, description, public: true }); + }; + + register({ + get: (path, handler, description, surface) => addHttpRoute('GET', path, handler, description, surface), + post: (path, handler, description, surface) => addHttpRoute('POST', path, handler, description, surface), + }); + + return Object.freeze(routes); +} + +export function cloneHttpRoutes(routes: readonly HttpRouteDefinition[]): HttpRouteDefinition[] { + return routes.map(route => ({ ...route })); +} diff --git a/src/server/server.ts b/src/server/server.ts index 8b126da..2b9898c 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -6,18 +6,23 @@ */ import * as http from 'node:http'; -import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { writeFileSync, unlinkSync } from 'node:fs'; import { Graph } from '../graph/graph.js'; import { loadConfig } from '../config/config.js'; -import { GRAPH_PATH, LAZYBRAIN_DIR } from '../constants.js'; +import { GRAPH_PATH } from '../constants.js'; import { createRouter } from './router.js'; import { getPackageVersion } from '../version.js'; +import { DEFAULT_PORT, SERVER_PID_FILE, SERVER_RUNNING_FLAG } from './liveness.js'; -export const DEFAULT_PORT = 18450; -export const SERVER_RUNNING_FLAG = join(LAZYBRAIN_DIR, '.server-running'); -export const SERVER_PID_FILE = join(LAZYBRAIN_DIR, 'server.pid'); +export { + DEFAULT_PORT, + SERVER_PID_FILE, + SERVER_RUNNING_FLAG, + getServerPid, + getServerPort, + getServerRuntimeState, + isServerRunning, +} from './liveness.js'; export interface ServerInstance { server: http.Server; @@ -57,21 +62,3 @@ export function createServer(port: number = DEFAULT_PORT): ServerInstance { }, }; } - -export function isServerRunning(): boolean { - return existsSync(SERVER_RUNNING_FLAG); -} - -export function getServerPort(): number { - if (!existsSync(SERVER_RUNNING_FLAG)) return DEFAULT_PORT; - const raw = readFileSync(SERVER_RUNNING_FLAG, 'utf-8').trim(); - const n = parseInt(raw, 10); - return isNaN(n) ? DEFAULT_PORT : n; -} - -export function getServerPid(): number | null { - if (!existsSync(SERVER_PID_FILE)) return null; - const raw = readFileSync(SERVER_PID_FILE, 'utf-8').trim(); - const n = parseInt(raw, 10); - return isNaN(n) ? null : n; -} diff --git a/src/server/status.ts b/src/server/status.ts index ff608ab..ed29734 100644 --- a/src/server/status.ts +++ b/src/server/status.ts @@ -3,7 +3,7 @@ import { join, resolve } from 'node:path'; import { loadavg } from 'node:os'; import type { Graph } from '../graph/graph.js'; import type { UserConfig } from '../types.js'; -import { EMBEDDINGS_BIN_PATH, EMBEDDINGS_INDEX_PATH, GRAPH_PATH, LAZYBRAIN_DIR, STATUS_PATH, getClaudeConfigDir } from '../constants.js'; +import { EMBEDDINGS_BIN_PATH, EMBEDDINGS_INDEX_PATH, GRAPH_PATH, STATUS_PATH, getClaudeConfigDir } from '../constants.js'; import { getPackageVersion } from '../version.js'; import { redactConfig } from '../config/redaction.js'; import { getEmbeddingCacheStatus } from '../embeddings/cache.js'; @@ -13,9 +13,10 @@ import { evaluateReady } from '../hook/readiness.js'; import { getHookLifecycleStatus } from '../hook/status.js'; import type { HookInstallScope } from '../hook/types.js'; import { scanAgentInventory } from '../lab/agent-inventory.js'; - -const SERVER_RUNNING_FLAG = join(LAZYBRAIN_DIR, '.server-running'); -const SERVER_PID_FILE = join(LAZYBRAIN_DIR, 'server.pid'); +import { buildModelHealth, buildUnlockHealth } from '../unlock/health.js'; +import { getGitNexusStatus } from '../integrations/gitnexus.js'; +import { hasLocalActiveJob } from '../runtime/jobs.js'; +import { getServerRuntimeState } from './liveness.js'; function readJson(path: string): Record | null { if (!existsSync(path)) return null; @@ -32,11 +33,47 @@ function getSettingsPath(scope: HookInstallScope): string { : join(getClaudeConfigDir(), 'settings.json'); } +function getHooksPath(scope: HookInstallScope): string { + return scope === 'project' + ? join(resolve(process.cwd(), '.claude'), 'hooks', 'hooks.json') + : join(getClaudeConfigDir(), 'hooks', 'hooks.json'); +} + function readSettings(path: string): Record { const json = readJson(path); return json ?? {}; } +function readHooks(path: string): Record { + const json = readJson(path); + return ((json?.hooks as Record | undefined) ?? json) ?? {}; +} + +function mergeHookMaps(...hookMaps: Array | undefined>): Record { + const merged: Record = {}; + for (const hookMap of hookMaps) { + if (!hookMap) continue; + for (const [eventName, eventHooks] of Object.entries(hookMap)) { + if (Array.isArray(eventHooks)) { + const existing = merged[eventName]; + merged[eventName] = Array.isArray(existing) + ? [...existing, ...eventHooks] + : [...eventHooks]; + } else if (eventHooks !== undefined) { + merged[eventName] = eventHooks; + } + } + } + return merged; +} + +function settingsWithMergedHooks(settings: Record, hooks: Record): Record { + return { + ...settings, + hooks: mergeHookMaps(settings.hooks as Record | undefined, hooks), + }; +} + function apiConfigured(config: UserConfig): { compile: boolean; secretary: boolean; embedding: boolean } { return { compile: Boolean(config.compileApiBase && config.compileApiKey && config.compileModel), @@ -45,37 +82,102 @@ function apiConfigured(config: UserConfig): { compile: boolean; secretary: boole }; } -function getServerPort(): number { - const raw = existsSync(SERVER_RUNNING_FLAG) ? readFileSync(SERVER_RUNNING_FLAG, 'utf-8').trim() : ''; - const parsed = parseInt(raw, 10); - return Number.isFinite(parsed) ? parsed : 18450; +function hasLocalRuntimeJob(state: unknown): boolean { + if (state === 'compiling' || state === 'scanning') { + return hasLocalActiveJob('compile') || hasLocalActiveJob('scan'); + } + if (state === 'embedding') return hasLocalActiveJob('embedding'); + return true; } -function getServerPid(): number | null { - const raw = existsSync(SERVER_PID_FILE) ? readFileSync(SERVER_PID_FILE, 'utf-8').trim() : ''; - const parsed = parseInt(raw, 10); - return Number.isFinite(parsed) ? parsed : null; +function publicRuntimeStatus(status: Record | null): Record { + const allowed = new Set([ + 'state', + 'progress', + 'updatedAt', + 'lastScanAt', + 'lastCompileAt', + 'lastEmbeddingAt', + 'lastEmbeddingResult', + 'scannedFiles', + 'scannedPaths', + 'capabilitiesFound', + 'newCapabilities', + ]); + const out: Record = {}; + if (!status) return out; + for (const key of allowed) { + const value = status[key]; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') out[key] = value; + if (Array.isArray(value)) out[key] = value.filter(item => typeof item === 'string').slice(0, 20); + } + if ( + (status.state === 'compiling' || status.state === 'scanning' || status.state === 'embedding') && + !hasLocalRuntimeJob(status.state) + ) { + out.stale = true; + out.staleReason = status.state === 'embedding' ? 'no active embedding process' : 'no active compile process'; + } + return out; +} + +function buildProductReadiness( + graphExists: boolean, + nodes: ReturnType, + compileErrors: string[], + embedding: ReturnType, + config: UserConfig, + apiState: ReturnType, +): Record { + const blockers: string[] = []; + const warnings: string[] = []; + if (!graphExists) blockers.push(`Graph is missing: ${GRAPH_PATH}`); + if (nodes.length === 0) blockers.push('Graph has no capabilities.'); + if (compileErrors.length > 0) blockers.push(`Graph has ${compileErrors.length} compile errors.`); + if ((config.engine === 'semantic' || config.engine === 'hybrid') && (embedding.state === 'missing' || embedding.state === 'invalid')) { + blockers.push(`Embedding cache is ${embedding.state}.`); + } else if (embedding.state === 'stale') { + warnings.push(embedding.message); + } + if (!apiState.compile) warnings.push('Compile API is not fully configured.'); + if (!apiState.secretary) warnings.push('Secretary API is not fully configured.'); + if ((config.engine === 'semantic' || config.engine === 'hybrid') && !apiState.embedding) { + warnings.push('Embedding API is not fully configured.'); + } + return { + state: blockers.length === 0 ? 'READY' : 'NOT_READY', + blockers, + warnings, + }; } export function buildStatusReport(graph: Graph, config: UserConfig): Record { const nodes = graph.getAllNodes(); + const graphExists = existsSync(GRAPH_PATH); + const compileErrors = graph.getCompileErrors(); const runtime = getHookRuntimeSnapshot({ config }); + const status = readJson(STATUS_PATH); + const runtimeStatus = publicRuntimeStatus(status); + const statusForReady = runtimeStatus.stale === true ? { ...(status ?? {}), state: 'idle' } : status; const scopes = (['project', 'global'] as const).map((scope) => { const settingsPath = getSettingsPath(scope); - const settings = readSettings(settingsPath); + const hooksPath = getHooksPath(scope); + const settings = settingsWithMergedHooks(readSettings(settingsPath), readHooks(hooksPath)); const installState = readHookInstallStateForScope(scope, scope === 'project' ? process.cwd() : undefined); const lifecycle = getHookLifecycleStatus(settings, { runtime, installState }); - return { scope, settingsPath, settings, installState, lifecycle }; + return { scope, settingsPath, hooksPath, settings, installState, lifecycle }; }); - const readyScopes = scopes.map(({ scope, settingsPath, settings, installState }) => ({ + const readyScopes = scopes.map(({ scope, settingsPath, hooksPath, settings, installState }) => ({ scope, settingsPath, + hooksPath, settings, installState, })); const ready = evaluateReady({ - graphExists: existsSync(GRAPH_PATH), - status: readJson(STATUS_PATH), + graphExists, + compileErrors, + status: statusForReady, runtime, scopes: readyScopes, cwd: process.cwd(), @@ -86,14 +188,21 @@ export function buildStatusReport(graph: Graph, config: UserConfig): Record>((acc, node) => { acc[node.kind] = (acc[node.kind] ?? 0) + 1; @@ -104,21 +213,28 @@ export function buildStatusReport(graph: Graph, config: UserConfig): Record ({ + scopes: scopes.map(({ scope, settingsPath, hooksPath, installState, lifecycle }) => ({ scope, settingsPath, + hooksPath, installed: lifecycle.lazybrainUserPromptSubmit, stopClean: !lifecycle.lazybrainStop, sessionStart: lifecycle.lazybrainSessionStart, + userPromptSubmitCount: lifecycle.lazybrainUserPromptSubmitCount, + duplicateUserPromptSubmit: lifecycle.duplicateLazyBrainUserPromptSubmit, installState: installState ? { scope: installState.scope, workspaceRoot: installState.workspaceRoot, @@ -144,10 +260,10 @@ export function buildStatusReport(graph: Graph, config: UserConfig): Record JSON.parse(l) as HistoryEntry); + return raw.split('\n').filter(Boolean).map(l => sanitizePromptRecord(JSON.parse(l) as HistoryEntry) as HistoryEntry); } catch { return []; } diff --git a/src/stats/session-summary.ts b/src/stats/session-summary.ts index ce8900e..80648ad 100644 --- a/src/stats/session-summary.ts +++ b/src/stats/session-summary.ts @@ -13,6 +13,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { HISTORY_PATH, LAZYBRAIN_DIR } from '../constants.js'; import type { HistoryEntry } from '../types.js'; +import { sanitizePromptRecord } from '../privacy/prompts.js'; const USAGE_PATH = join(LAZYBRAIN_DIR, 'usage.jsonl'); @@ -83,7 +84,7 @@ function loadHistoryEntries(historyPath?: string): HistoryEntry[] { try { const raw = readFileSync(path, 'utf-8').trim(); if (!raw) return []; - return raw.split('\n').filter(Boolean).map(l => JSON.parse(l) as HistoryEntry); + return raw.split('\n').filter(Boolean).map(l => sanitizePromptRecord(JSON.parse(l) as HistoryEntry) as HistoryEntry); } catch { return []; } diff --git a/src/types.ts b/src/types.ts index d4d9bd6..9260478 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,17 @@ export interface CapabilityMeta { lastUpdated?: string; } +export type CapabilitySideEffect = + | 'reads_files' + | 'writes_files' + | 'executes_commands' + | 'network' + | 'changes_config' + | 'installs_hooks' + | 'publishes' + | 'destructive' + | 'unknown'; + // ─── Skill Schema (Frontmatter Extension) ────────────────────────────────── export type RouteTarget = 'generic' | 'claude' | 'codex' | 'cursor'; @@ -82,6 +93,12 @@ export interface Capability { description: string; /** Source ecosystem: "ECC", "OMC", "plugin", "external", etc. */ origin: string; + /** Normalized provider used for conflict diagnostics */ + provider?: string; + /** Capabilities with the same conflict group should be ranked, not blindly chained */ + conflictGroup?: string; + /** Coarse side effects for safe routing and doctor diagnostics */ + sideEffects?: CapabilitySideEffect[]; /** Installation status */ status: 'installed' | 'available' | 'disabled'; /** Platforms this capability works on */ @@ -173,12 +190,19 @@ export interface GovernanceDecision { // ─── Link (Graph Edge) ────────────────────────────────────────────────────── -export type LinkType = - | 'similar_to' // Functionally similar, needs comparison - | 'composes_with' // Can be used together - | 'supersedes' // Replaces an older version - | 'depends_on' // Requires another capability - | 'belongs_to'; // Ecosystem membership +export const LINK_TYPES = [ + 'similar_to', + 'composes_with', + 'supersedes', + 'depends_on', + 'belongs_to', +] as const; + +export type LinkType = typeof LINK_TYPES[number]; + +export function isLinkType(value: unknown): value is LinkType { + return typeof value === 'string' && (LINK_TYPES as readonly string[]).includes(value); +} /** * A bidirectional edge between two capabilities. @@ -204,6 +228,7 @@ export interface CapabilityGraph { version: string; compiledAt: string; compileModel?: string; + compileErrors?: string[]; nodes: Capability[]; links: Link[]; categories: string[]; @@ -272,6 +297,9 @@ export interface RouteSkillRef { kind: CapabilityKind; category: string; origin: string; + provider?: string; + conflictGroup?: string; + sideEffects?: CapabilitySideEffect[]; available: boolean; score?: number; layer?: MatchLayer; @@ -292,6 +320,46 @@ export interface RouteTokenStrategy { summary: string; } +export type ChoiceOptionKind = 'mode' | 'model' | 'skill' | 'plugin' | 'workflow'; +export type ChoiceCost = 'low' | 'medium' | 'high'; +export type ChoiceLatency = 'fast' | 'normal' | 'slow'; +export type ChoiceRisk = 'low' | 'medium' | 'high'; + +export interface ChoiceOption { + id: string; + kind: ChoiceOptionKind; + label: string; + confidence: number; + cost: ChoiceCost; + latency: ChoiceLatency; + risk: ChoiceRisk; + reason: string; + command?: string; +} + +export interface ConflictNotice { + group: string; + winner: string; + suppressed: string[]; + reason: string; + suggestedAction?: string; + severity: 'info' | 'warn' | 'block'; +} + +export interface DecisionPolicy { + defaultAction: 'auto' | 'ask' | 'skip'; + askUser: boolean; + reason: string; +} + +export interface ChoiceSet { + intent: string; + recommended: ChoiceOption; + alternatives: ChoiceOption[]; + conflicts: ConflictNotice[]; + policy: DecisionPolicy; +} + export interface RouteSpec { schemaVersion: string; query: string; @@ -302,6 +370,9 @@ export interface RouteSpec { whyRoute: string; mustCallLazyBrainReason?: string; combo?: string; + entryCommand?: string; + executionMode?: 'advisory' | 'guided'; + modelStrategy?: string; skills: RouteSkillRef[]; executionPlan: WorkflowStep[]; contextNeeded: string[]; @@ -309,6 +380,7 @@ export interface RouteSpec { verification: VerificationRequirement[]; doneWhen: string[]; tokenStrategy: RouteTokenStrategy; + choices: ChoiceSet; adapters: { generic: RouteAdapterPayload; claude?: RouteAdapterPayload; @@ -316,6 +388,7 @@ export interface RouteSpec { cursor?: RouteAdapterPayload; }; warnings: string[]; + unlockWarnings?: string[]; clarificationQuestions?: string[]; } @@ -490,7 +563,10 @@ export interface UserConfig { export interface HistoryEntry { timestamp: string; + /** Privacy-preserving display label, not the raw user prompt. */ query: string; + /** Hash of the raw prompt when available. */ + queryHash?: string; matched: string; id?: string; accepted: boolean; @@ -581,6 +657,9 @@ export interface RawCapability { name: string; description: string; origin: string; + provider?: string; + conflictGroup?: string; + sideEffects?: CapabilitySideEffect[]; filePath: string; triggers?: string[]; compatibility: Platform[]; @@ -601,6 +680,7 @@ export interface LLMProviderConfig { model: string; apiBase: string; apiKey?: string; + maxTokens?: number; } export interface LLMResponse { diff --git a/src/ui/html.ts b/src/ui/html.ts index b6846c6..36038ab 100644 --- a/src/ui/html.ts +++ b/src/ui/html.ts @@ -3,1270 +3,308 @@ export const UI_HTML = ` - LazyBrain 管理面板 + LazyBrain Workbench - -

- -
- - - -
-
- -
- - -
-
LB
-

检测状态䞭...

-

正圚加蜜 LazyBrain 配眮

-
-
- -
- - -
-
-

试试看

- 蟓入任务描述查看 LazyBrain 掚荐结果 +
+
+
+

LazyBrain Workbench

+
只星瀺已闭环胜力route、MCP、compile、embedding、ready、diagnostics。
-
-
- - -
-
- - - - - -
-
-
蟓入任务描述后点击"获取掚荐"
-
+
+ +
- -
`; diff --git a/src/unlock/health.ts b/src/unlock/health.ts new file mode 100644 index 0000000..7f1a7f3 --- /dev/null +++ b/src/unlock/health.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Graph } from '../graph/graph.js'; +import type { UserConfig } from '../types.js'; +import { GRAPH_PATH, LAZYBRAIN_DIR, STATUS_PATH } from '../constants.js'; +import { getEmbeddingCacheStatus } from '../embeddings/cache.js'; + +export interface UnlockHealth { + lastScanAt?: string; + lastCompileAt?: string; + lastEmbeddingAt?: string; + activeNodes: number; + embeddedNodes: number; + missingEmbeddings: number; + embeddingCoveragePercent: number; + recentNewCapabilities: string[]; + scanState?: string; + compileState?: string; + embeddingState: string; +} + +export interface ModelHealth { + compile: { configured: boolean; model?: string; apiBase?: string }; + secretary: { configured: boolean; model?: string; apiBase?: string }; + embedding: { configured: boolean; model?: string; apiBase?: string; dim?: number | null; coveragePercent: number }; +} + +function readJson(path: string): Record | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf-8')) as Record; + } catch { + return null; + } +} + +function isoFromMs(value: unknown): string | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? new Date(value).toISOString() + : typeof value === 'string' + ? value + : undefined; +} + +function publicBase(apiBase?: string): string | undefined { + return apiBase?.replace(/\/$/, '').replace(/\/v\d+.*$/, '/v*'); +} + +function graphCompiledAt(): string | undefined { + const raw = readJson(GRAPH_PATH); + if (typeof raw?.compiledAt === 'string') return raw.compiledAt; + try { + return statSync(GRAPH_PATH).mtime.toISOString(); + } catch { + return undefined; + } +} + +export function buildUnlockHealth(graph: Graph): UnlockHealth { + const nodes = graph.getAllNodes(); + const activeById = new Map(nodes.filter(node => node.status !== 'disabled').map(node => [node.id, node])); + const embedding = getEmbeddingCacheStatus(nodes); + const status = readJson(STATUS_PATH); + const scanCachePath = join(LAZYBRAIN_DIR, 'scan-cache.json'); + const missingNames = embedding.missingIds + .map(id => activeById.get(id)?.name) + .filter((name): name is string => Boolean(name)) + .slice(0, 10); + const statusNew = Array.isArray(status?.newCapabilities) + ? status.newCapabilities.filter((name): name is string => typeof name === 'string').slice(0, 10) + : []; + const scanState = typeof status?.state === 'string' && status.state === 'scanning' ? status.state : undefined; + const compileState = typeof status?.state === 'string' && status.state === 'compiling' ? status.state : undefined; + let lastScanAt = isoFromMs(status?.lastScanAt); + if (!lastScanAt && existsSync(scanCachePath)) { + try { lastScanAt = statSync(scanCachePath).mtime.toISOString(); } catch {} + } + + return { + lastScanAt, + lastCompileAt: isoFromMs(status?.lastCompileAt) ?? graphCompiledAt(), + lastEmbeddingAt: embedding.updatedAt, + activeNodes: embedding.active, + embeddedNodes: embedding.covered, + missingEmbeddings: embedding.missingIds.length, + embeddingCoveragePercent: embedding.coveragePercent, + recentNewCapabilities: missingNames.length > 0 ? missingNames : statusNew, + scanState, + compileState, + embeddingState: embedding.state, + }; +} + +export function buildModelHealth(config: UserConfig, graph: Graph): ModelHealth { + const embedding = getEmbeddingCacheStatus(graph.getAllNodes()); + return { + compile: { + configured: Boolean(config.compileApiBase && config.compileApiKey && config.compileModel), + model: config.compileModel, + apiBase: publicBase(config.compileApiBase), + }, + secretary: { + configured: Boolean((config.secretaryApiBase ?? config.compileApiBase) && (config.secretaryApiKey ?? config.compileApiKey) && (config.secretaryModel ?? config.compileModel)), + model: config.secretaryModel ?? config.compileModel, + apiBase: publicBase(config.secretaryApiBase ?? config.compileApiBase), + }, + embedding: { + configured: Boolean(config.embeddingApiBase && config.embeddingApiKey && config.embeddingModel), + model: config.embeddingModel, + apiBase: publicBase(config.embeddingApiBase), + dim: embedding.dim, + coveragePercent: embedding.coveragePercent, + }, + }; +} diff --git a/src/utils/hud-normalizer.ts b/src/utils/hud-normalizer.ts index 8f088fa..07a4bf3 100644 --- a/src/utils/hud-normalizer.ts +++ b/src/utils/hud-normalizer.ts @@ -4,7 +4,7 @@ export function simplifyUpstreamHud(text: string): string { .replace(/tok:\s*([^\s(]+)\s*\([^)]*\)/g, '环计消耗 $1 tok'); } -// Always show LazyBrain in combined statusline so users can see active/dormant state -export function isLowSignalLazyBrainLabel(_label: string): boolean { - return false; +export function isLowSignalLazyBrainLabel(label: string): boolean { + const clean = label.replace(/\x1b\[[0-9;]*m/g, '').trim(); + return clean.includes('埅机䞭') || clean.includes('䞊次') || clean.includes('已跳过'); } diff --git a/src/utils/query-normalizer.ts b/src/utils/query-normalizer.ts index f9ecd6f..a63a22b 100644 --- a/src/utils/query-normalizer.ts +++ b/src/utils/query-normalizer.ts @@ -53,8 +53,12 @@ const ABSTRACT_EXPANSIONS: Array<[RegExp, string[]]> = [ ['产品', '定䜍', '甚户价倌', '䜓验', 'product', 'strategy', 'ux']], [/(怎么发垃|怎麌癌垃|劂䜕公垃|䞊线|䞊線|公匀|公開|掚广|掚廣|变现|變珟|商䞚化|商業化)/i, ['发垃', '郚眲', '文档', '营销', '定价', 'release', 'go-to-market', 'monetization']], + [/(project planning|project plan|plan project)/i, + ['project', 'planning', 'planner', 'omc-plan', 'product-capability']], [/(deploy to production|production deploy|ship to production)/i, ['deployment', 'production', 'release', 'frontend', 'product', 'ai engineer', 'product-capability', 'frontend-design']], + [/(重构敎䞪后端|重構敎個埌端|后端重构|埌端重構|refactor.*backend|backend.*refactor)/i, + ['backend', 'refactor', 'architecture', 'backend-patterns', 'backend architect', 'refactor-clean']], [/(预算|預算|倪莵|倪貎|省钱|省錢|成本|烧钱|燒錢|额床|額床)/i, ['预算', '成本', '暡型路由', '䌘化', 'budget', 'cost', 'routing']], [/(䞍枅晰|䞍盎观|䞍盎觀|展瀺|星瀺|顯瀺|界面|介面|hud|ui|桌面宠物|桌面寵物)/i, @@ -80,22 +84,22 @@ const ABSTRACT_EXPANSIONS: Array<[RegExp, string[]]> = [ [/(写 python 代码|寫 python 代碌|python 匀发|python開癌)/i, ['python', '代码', '匀发', 'patterns', 'review', 'python-review', 'python-patterns', 'code-review']], [/(kotlin 匀发最䜳实践|kotlin最䜳实践|kotlin 匀发|kotlin開癌)/i, - ['kotlin', '匀发', '最䜳实践', 'review', 'test', 'kotlin-review', 'kotlin-test', 'android-native-dev']], + ['kotlin', '匀发', '最䜳实践', 'patterns', 'review', 'test', 'kotlin-patterns', 'kotlin-review', 'kotlin-test', 'android-native-dev']], [/(go 语蚀匀发|go语蚀匀发|golang 匀发|golang匀发)/i, ['go', 'golang', '匀发', 'backend', 'patterns', 'go-build', 'go-review']], [/(spring boot 匀发|springboot 匀发|spring boot)/i, - ['spring', 'springboot', 'backend', 'java', 'development', 'backend-patterns', 'debugger', 'project-session-manager']], + ['spring', 'springboot', 'backend', 'java', 'development', 'patterns', 'springboot-patterns', 'springboot-tdd', 'springboot-verification']], [/(performance optimization|optimize performance|性胜䌘化)/i, ['performance', 'optimization', 'benchmark', 'optimizer', 'performance benchmarker', 'prompt-optimize', 'database optimizer']], [/(写单元测试|單元枬詊|add unit tests?|unit tests?)/i, - ['测试', '单元测试', 'test', 'unit-test', 'coverage', 'tdd', 'test-coverage', 'cpp-test', 'flutter-test', 'python-testing']], + ['测试', '单元测试', '单测', 'test', 'unit-test', 'coverage', 'tdd', 'test-coverage', 'tdd-workflow', 'test-engineer']], [/(提亀代码|提亀代碌|git commit|commit code)/i, ['提亀', 'commit', 'git', 'code-review', 'git-commit', 'git-master', 'prp-commit']], [/(重构代码让它曎简掁|重構代碌讓它曎簡朔|代码重构减少倍杂床|代碌重構枛少耇雜床|refactor for readability|simplify complex code)/i, - ['重构', '简化', '可读性', '倍杂床', 'refactor', 'simplify', 'readability', 'code-simplifier', 'refactor-clean', 'minimal-change', 'ai-slop-cleaner']], + ['重构', '简化', '可读性', '倍杂床', 'refactor', 'simplify', 'readability', 'code-simplifier', 'refactor-clean', 'refactor-method-complexity-reduce', 'minimal-change', 'ai-slop-cleaner']], [/(修䞪 typo|ä¿® typo|改䞪 typo|小修䞀䞋|修䞪错字|修䞪筆誀)/i, ['typo', 'small-fix', 'minimal-change', 'fix', 'build-fix']], - [/(对抗性双暡型审查|對抗性雙暡型審查|双暡型审查|雙暡型審查)/i, + [/(adversarial dual review|dual model review|对抗性双暡型审查|對抗性雙暡型審查|双暡型审查|雙暡型審查)/i, ['adversarial', 'dual-review', 'critic', 'code-reviewer', 'santa-loop']], [/(ralph.*bug|ralph.*错误|ralph.*錯誀|ralph.*问题|ralph.*問題)/i, ['ralph', 'debugger', 'agent-introspection-debugging']], diff --git a/test/benchmark/golden-set.json b/test/benchmark/golden-set.json index 7853fa3..74a666b 100644 --- a/test/benchmark/golden-set.json +++ b/test/benchmark/golden-set.json @@ -8,7 +8,7 @@ }, { "query": "审查这䞪PR", - "expected": ["review-pr", "code-review", "Code Reviewer"], + "expected": ["gitnexus-pr-review", "review-pr", "code-review", "Code Reviewer"], "expectedNot": [], "topK": 3, "note": "äž­æ–‡ PR 审查" @@ -29,7 +29,7 @@ }, { "query": "refactor for readability", - "expected": ["code-simplifier", "refactor-clean", "planner"], + "expected": ["refactor-method-complexity-reduce", "code-simplifier", "refactor-clean", "planner"], "expectedNot": ["Tax Strategist"], "topK": 3, "note": "英文重构" @@ -113,7 +113,7 @@ }, { "query": "Kotlin 匀发最䜳实践", - "expected": ["kotlin-test", "kotlin-review", "android-native-dev"], + "expected": ["kotlin-patterns", "kotlin-test", "kotlin-review", "android-native-dev"], "expectedNot": [], "topK": 3, "note": "Kotlin 匀发" @@ -134,14 +134,14 @@ }, { "query": "安党挏掞扫描", - "expected": ["security-review", "security-reviewer", "security-bounty-hunter"], + "expected": ["security-scan", "security-review", "security-reviewer", "security-bounty-hunter"], "expectedNot": [], "topK": 3, "note": "䞭文安党扫描" }, { "query": "security vulnerability scan", - "expected": ["security-review", "security-reviewer", "security-bounty-hunter"], + "expected": ["security-scan", "security-review", "security-reviewer", "security-bounty-hunter"], "expectedNot": [], "topK": 3, "note": "英文安党扫描" @@ -176,7 +176,7 @@ }, { "query": "前端 UI 讟计", - "expected": ["frontend-design", "designer", "Frontend Developer"], + "expected": ["frontend-dev", "frontend-design", "designer", "Frontend Developer"], "expectedNot": [], "topK": 3, "note": "䞭文前端讟计" @@ -267,7 +267,7 @@ }, { "query": "代码库新人䞊手", - "expected": ["claude-code-bridge", "code-review", "skill-create", "code-tour"], + "expected": ["Codebase Onboarding Engineer", "claude-code-bridge", "code-review", "skill-create", "code-tour"], "expectedNot": [], "topK": 3, "note": "äž­æ–‡ onboarding" @@ -323,17 +323,17 @@ }, { "query": "Flutter 匀发", - "expected": ["flutter-build", "flutter-test", "flutter-review"], + "expected": ["dart-flutter-patterns", "flutter-build", "flutter-test", "flutter-review"], "expectedNot": [], "topK": 3, "note": "Flutter 匀发" }, { "query": "Spring Boot 匀发", - "expected": ["project-session-manager", "debugger", "backend-patterns"], + "expected": ["springboot-patterns", "springboot-tdd", "springboot-verification"], "expectedNot": [], "topK": 3, - "note": "Spring Boot 匀发embedding 误匹配" + "note": "Spring Boot 匀发" }, { "query": "MCP server 匀发", @@ -351,7 +351,7 @@ }, { "query": "代码重构减少倍杂床", - "expected": ["code-simplifier", "refactor-clean", "ai-slop-cleaner"], + "expected": ["refactor-method-complexity-reduce", "code-simplifier", "refactor-clean", "ai-slop-cleaner"], "expectedNot": ["Tax Strategist"], "topK": 3, "note": "䞭文重构倍杂床" diff --git a/test/benchmark/match-quality.test.ts b/test/benchmark/match-quality.test.ts index cb13ab7..fcbc3c5 100644 --- a/test/benchmark/match-quality.test.ts +++ b/test/benchmark/match-quality.test.ts @@ -2,7 +2,7 @@ * LazyBrain — Matching Quality Benchmark * * Measures top-1 and top-3 hit rate against a golden set of queries. - * Target: top-1 >= 60%, top-3 >= 80% (保守目标真实标泚后) + * Target: top-1 >= 90%, top-3 >= 96% on the current local graph. * * Uses the full match() orchestrator (alias → tag → semantic → graph enrichment) * to reflect real-world behavior, not just the tag layer in isolation. @@ -85,9 +85,10 @@ describe('matching quality — individual cases (log only, no assertions)', () = // ─── Aggregate hit rate ─────────────────────────────────────────────────────── describe.skipIf(process.env.CI === 'true')('matching quality — aggregate', { timeout: 120000 }, () => { - it('top-1 >= 60%, top-3 >= 80%', async () => { + it('top-1 >= 90%, top-3 >= 96%', async () => { let top1Hits = 0; let top3Hits = 0; + const top1Misses: Array<{ query: string; got: string; expected: string[] }> = []; const misses: Array<{ query: string; got: string[]; expected: string[] }> = []; for (const c of goldenSet) { @@ -99,6 +100,12 @@ describe.skipIf(process.env.CI === 'true')('matching quality — aggregate', { t // top-1 呜䞭 if (top1.some(n => checkMatch(n, c.expected))) { top1Hits++; + } else { + top1Misses.push({ + query: c.query, + got: top1[0] ?? '', + expected: c.expected, + }); } // top-3 呜䞭 @@ -119,18 +126,25 @@ describe.skipIf(process.env.CI === 'true')('matching quality — aggregate', { t console.log(`\nTop-1 hit rate: ${top1Hits}/${goldenSet.length} = ${(top1Rate * 100).toFixed(1)}%`); console.log(`Top-3 hit rate: ${top3Hits}/${goldenSet.length} = ${(top3Rate * 100).toFixed(1)}%`); - if (top3Rate < 0.8) { + if (top1Rate < 0.9) { + console.log('\nTop-1 Misses:'); + for (const m of top1Misses) { + console.log(` "${m.query}" -> ${m.got} (want: ${m.expected.join(' | ')})`); + } + } + + if (top3Rate < 0.96) { console.log('\nTop-3 Misses:'); for (const m of misses) { console.log(` "${m.query}" → [${m.got.join(', ')}] (want: ${m.expected.join(' | ')})`); } } - expect(top1Rate).toBeGreaterThanOrEqual(0.4); - expect(top3Rate).toBeGreaterThanOrEqual(0.65); + expect(top1Rate).toBeGreaterThanOrEqual(0.9); + expect(top3Rate).toBeGreaterThanOrEqual(0.96); }); - it('Chinese query top-1 >= 60%, top-3 >= 80%', async () => { + it('Chinese query top-1 >= 85%, top-3 >= 93%', async () => { const chineseCases = goldenSet.filter(c => /[\u4e00-\u9fff]/.test(c.query)); let top1Hits = 0; let top3Hits = 0; @@ -151,8 +165,8 @@ describe.skipIf(process.env.CI === 'true')('matching quality — aggregate', { t console.log(`\nChinese Top-1: ${top1Hits}/${chineseCases.length} = ${(top1Rate * 100).toFixed(1)}%`); console.log(`Chinese Top-3: ${top3Hits}/${chineseCases.length} = ${(top3Rate * 100).toFixed(1)}%`); - expect(top1Rate).toBeGreaterThanOrEqual(0.3); - expect(top3Rate).toBeGreaterThanOrEqual(0.6); + expect(top1Rate).toBeGreaterThanOrEqual(0.85); + expect(top3Rate).toBeGreaterThanOrEqual(0.93); }); }); diff --git a/test/compiler/compile-errors.test.ts b/test/compiler/compile-errors.test.ts new file mode 100644 index 0000000..b5bed61 --- /dev/null +++ b/test/compiler/compile-errors.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { classifyCompileError, formatCompileErrorReport, summarizeCompileErrors } from '../../src/compiler/compile-errors.js'; + +describe('compile error reporting', () => { + it('classifies structured compiler errors by prefix', () => { + expect(classifyCompileError('relation_invalid_type:source->target: blocks')).toBe('relation_invalid_type'); + expect(classifyCompileError('plain failure')).toBe('unknown'); + }); + + it('summarizes error counts by code', () => { + const summary = summarizeCompileErrors([ + 'relation_invalid_type:a', + 'relation_invalid_type:b', + 'relation_target_missing:c', + ]); + + expect(summary.total).toBe(3); + expect(summary.byCode).toEqual({ + relation_invalid_type: 2, + relation_target_missing: 1, + }); + }); + + it('formats a bounded human-readable report', () => { + const report = formatCompileErrorReport([ + 'relation_invalid_type:a', + 'relation_target_missing:b', + 'relation_parse_failed:c', + ], 2); + + expect(report).toContain('Persisted compile errors: 3'); + expect(report).toContain('relation_invalid_type: 1'); + expect(report).toContain('First 2 errors'); + expect(report).toContain('... 1 more'); + }); +}); diff --git a/test/compiler/compiler-prompts.test.ts b/test/compiler/compiler-prompts.test.ts new file mode 100644 index 0000000..4db0de0 --- /dev/null +++ b/test/compiler/compiler-prompts.test.ts @@ -0,0 +1,274 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { compile } from '../../src/compiler/compiler.js'; +import { Graph } from '../../src/graph/graph.js'; +import type { LLMProvider, RawCapability } from '../../src/types.js'; + +function raw(name: string): RawCapability { + return { + kind: 'skill', + name, + description: `${name} description`, + origin: 'test', + filePath: `/test/${name}/SKILL.md`, + compatibility: ['claude-code'], + triggers: ['ui', 'frontend'], + }; +} + +function recorder(prompts: string[]): LLMProvider { + return { + async complete(prompt: string) { + prompts.push(prompt); + if (prompt.includes('REL_CUSTOM')) { + return { content: '[]', inputTokens: 1, outputTokens: 1 }; + } + return { + content: JSON.stringify({ + tags: ['frontend', 'ui'], + exampleQueries: ['redesign this page'], + category: 'frontend', + scenario: 'Use for frontend UI work', + }), + inputTokens: 1, + outputTokens: 1, + }; + }, + }; +} + +function relationResponder(content: string): LLMProvider { + return { + async complete(prompt: string) { + if (prompt.startsWith('Analyze this')) { + return { + content: JSON.stringify({ + tags: ['frontend', 'ui'], + exampleQueries: ['redesign this page'], + category: 'frontend', + scenario: 'Use for frontend UI work', + }), + inputTokens: 1, + outputTokens: 1, + }; + } + return { content, inputTokens: 1, outputTokens: 1 }; + }, + }; +} + +describe('compiler prompt overrides', () => { + it('uses compileTagPrompt for tag enrichment', async () => { + const prompts: string[] = []; + + await compile([raw('frontend-design')], { + llm: recorder(prompts), + modelName: 'test-model', + skipRelations: true, + config: { + compileTagPrompt: 'TAG_CUSTOM name=${name} kind=${kind} triggers=${triggers}', + }, + }); + + expect(prompts[0]).toContain('TAG_CUSTOM name=frontend-design'); + expect(prompts[0]).toContain('triggers=ui, frontend'); + }); + + it('uses compileRelationPrompt for relation inference', async () => { + const prompts: string[] = []; + + await compile([raw('frontend-design'), raw('design-review')], { + llm: recorder(prompts), + modelName: 'test-model', + forceRelations: true, + relationBatchSize: 2, + config: { + compileRelationPrompt: 'REL_CUSTOM cap=${cap.name} neighbors=${neighbors}', + }, + }); + + const relationPrompt = prompts.find(prompt => prompt.includes('REL_CUSTOM')); + expect(relationPrompt).toContain('cap=frontend-design'); + expect(relationPrompt).toContain('design-review'); + }); + + it('parses JSON from fenced model responses with comments', async () => { + const graph = await compile([raw('frontend-design')], { + llm: { + async complete() { + return { + content: `Here is the metadata: +\`\`\`json +{ + "tags": ["frontend", "ui"], // model copied a comment + "exampleQueries": ["redesign this page"], + "category": "frontend", + "scenario": "Use for frontend UI work", +} +\`\`\``, + inputTokens: 1, + outputTokens: 1, + }; + }, + }, + modelName: 'test-model', + skipRelations: true, + }); + + const node = graph.graph.findByName('frontend-design'); + expect(node?.category).toBe('frontend'); + expect(node?.tags).toEqual(['frontend', 'ui']); + }); + + it('parses relation arrays from noisy model responses', async () => { + let calls = 0; + + const result = await compile([raw('frontend-design'), raw('design-review')], { + llm: { + async complete() { + calls++; + if (calls <= 2) { + return { + content: JSON.stringify({ + tags: ['frontend', 'ui'], + exampleQueries: ['redesign this page'], + category: 'frontend', + scenario: 'Use for frontend UI work', + }), + inputTokens: 1, + outputTokens: 1, + }; + } + return { + content: `Relationships: +\`\`\`json +[ + { + "target": "design-review", + "type": "depends_on", + "description": "Review follows implementation", + "confidence": 0.8, + } +] +\`\`\``, + inputTokens: 1, + outputTokens: 1, + }; + }, + }, + modelName: 'test-model', + forceRelations: true, + }); + + expect(result.graph.getAllLinks().some(link => link.type === 'depends_on')).toBe(true); + }); + + it('rejects relation types outside the allowlist and persists compile errors', async () => { + const result = await compile([raw('frontend-design'), raw('design-review')], { + llm: relationResponder(JSON.stringify([ + { + target: 'design-review', + type: 'blocks', + description: 'Unsupported relation type', + confidence: 0.9, + }, + ])), + modelName: 'test-model', + forceRelations: true, + }); + + expect(result.graph.getAllLinks()).toHaveLength(0); + expect(result.errors.some(error => error.startsWith('relation_invalid_type:'))).toBe(true); + + const tempDir = mkdtempSync(join(tmpdir(), 'lazybrain-compile-')); + try { + const graphPath = join(tempDir, 'graph.json'); + result.graph.save(graphPath); + expect(Graph.load(graphPath).getCompileErrors()).toEqual(result.errors); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('reports missing relation targets instead of hiding them in debug output', async () => { + const result = await compile([raw('frontend-design'), raw('design-review')], { + llm: relationResponder(JSON.stringify([ + { + target: 'missing-skill', + type: 'depends_on', + description: 'Missing relation target', + confidence: 0.9, + }, + ])), + modelName: 'test-model', + forceRelations: true, + }); + + expect(result.errors.some(error => error.startsWith('relation_target_missing:'))).toBe(true); + expect(result.graph.getAllLinks()).toHaveLength(0); + }); + + it('classifies relation parse failures', async () => { + const result = await compile([raw('frontend-design'), raw('design-review')], { + llm: relationResponder('not json'), + modelName: 'test-model', + forceRelations: true, + }); + + expect(result.errors.some(error => error.startsWith('relation_parse_failed:'))).toBe(true); + }); + + it('classifies non-array relation responses instead of silently dropping them', async () => { + const result = await compile([raw('frontend-design'), raw('design-review')], { + llm: relationResponder(JSON.stringify({ + target: 'design-review', + type: 'depends_on', + confidence: 0.9, + })), + modelName: 'test-model', + forceRelations: true, + }); + + expect(result.errors.some(error => error.startsWith('relation_invalid_shape:'))).toBe(true); + expect(result.graph.getAllLinks()).toHaveLength(0); + }); + + it('preserves relation errors on tags-only compile until relations are forced', async () => { + const initial = await compile([raw('frontend-design'), raw('design-review')], { + llm: relationResponder(JSON.stringify([ + { + target: 'design-review', + type: 'blocks', + description: 'Unsupported relation type', + confidence: 0.9, + }, + ])), + modelName: 'test-model', + forceRelations: true, + }); + + expect(initial.errors.some(error => error.startsWith('relation_invalid_type:'))).toBe(true); + + const tagsOnly = await compile([raw('frontend-design'), raw('design-review')], { + llm: relationResponder('[]'), + modelName: 'test-model', + existingGraph: initial.graph, + skipRelations: true, + }); + + expect(tagsOnly.errors.some(error => error.startsWith('relation_invalid_type:'))).toBe(true); + expect(tagsOnly.graph.getCompileErrors()).toEqual(tagsOnly.errors); + + const repaired = await compile([raw('frontend-design'), raw('design-review')], { + llm: relationResponder('[]'), + modelName: 'test-model', + existingGraph: tagsOnly.graph, + forceRelations: true, + }); + + expect(repaired.errors.some(error => error.startsWith('relation_invalid_type:'))).toBe(false); + expect(repaired.graph.getCompileErrors()).toEqual([]); + }); +}); diff --git a/test/config/schema.test.ts b/test/config/schema.test.ts new file mode 100644 index 0000000..ad5b88c --- /dev/null +++ b/test/config/schema.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { validateConfigUpdate } from '../../src/config/schema.js'; + +describe('config schema', () => { + it('rejects unknown keys', () => { + expect(validateConfigUpdate({ arbitraryKey: 'value' })).toEqual({ + ok: false, + error: 'Unknown config key: arbitraryKey', + }); + }); + + it('validates enum values', () => { + expect(validateConfigUpdate({ strategy: 'recommend' })).toEqual({ + ok: false, + error: 'Invalid strategy. Must be one of: always-main, optimal, ask', + }); + expect(validateConfigUpdate({ strategy: 'optimal' })).toEqual({ + ok: true, + patch: { strategy: 'optimal' }, + ignoredKeys: [], + }); + }); + + it('validates autoThreshold bounds', () => { + expect(validateConfigUpdate({ autoThreshold: 1.1 })).toEqual({ + ok: false, + error: 'autoThreshold must be a finite number between 0 and 1', + }); + expect(validateConfigUpdate({ autoThreshold: 0.75 })).toEqual({ + ok: true, + patch: { autoThreshold: 0.75 }, + ignoredKeys: [], + }); + }); + + it('ignores blank secret values', () => { + expect(validateConfigUpdate({ + compileApiKey: '', + compileApiBase: 'https://api.example.test/v1', + })).toEqual({ + ok: true, + patch: { compileApiBase: 'https://api.example.test/v1' }, + ignoredKeys: ['compileApiKey'], + }); + }); + + it('requires string values for text config', () => { + expect(validateConfigUpdate({ compileModel: 123 })).toEqual({ + ok: false, + error: 'config key "compileModel" must be a string', + }); + }); +}); diff --git a/test/constants.test.ts b/test/constants.test.ts index ab07042..f37d67f 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -5,11 +5,13 @@ describe('getDefaultScanPaths', () => { it('defaults to Claude paths when no platform filter is provided', () => { const paths = getDefaultScanPaths(); expect(paths.some(path => path.includes('/.claude/'))).toBe(true); + expect(paths.some(path => path.includes('/.skillshub'))).toBe(true); }); it('does not include Claude paths when scanning only codex', () => { const paths = getDefaultScanPaths({ codex: true }); expect(paths.some(path => path.includes('/.claude/'))).toBe(false); + expect(paths.some(path => path.includes('/.skillshub'))).toBe(false); expect(paths.some(path => path.includes('/.codex/'))).toBe(true); }); diff --git a/test/diagnostics/conflicts.test.ts b/test/diagnostics/conflicts.test.ts new file mode 100644 index 0000000..73a7460 --- /dev/null +++ b/test/diagnostics/conflicts.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { detectCapabilityConflicts, inferCapabilityConflictGroup, inferCapabilityProvider, inferCapabilitySideEffects } from '../../src/diagnostics/conflicts.js'; +import type { Capability, RawCapability } from '../../src/types.js'; + +function raw(overrides: Partial & Pick): RawCapability { + return { + description: '', + filePath: '/tmp/tool.md', + compatibility: ['universal'], + ...overrides, + }; +} + +function cap(overrides: Partial & Pick): Capability { + return { + description: '', + status: 'installed', + compatibility: ['universal'], + tags: [], + exampleQueries: [], + category: 'other', + ...overrides, + }; +} + +describe('capability conflict diagnostics', () => { + it('derives provider, conflict group, and side effects', () => { + const capability = raw({ + kind: 'skill', + name: 'Release Manager', + origin: 'plugin', + description: 'Publish release, update config, and install hook rollback checks.', + }); + + expect(inferCapabilityProvider(capability)).toBe('plugin'); + expect(inferCapabilityConflictGroup(capability)).toBe('skill:release-manager'); + expect(inferCapabilitySideEffects(capability)).toEqual(expect.arrayContaining(['publishes', 'changes_config', 'installs_hooks'])); + }); + + it('reports same conflict group across providers', () => { + const conflicts = detectCapabilityConflicts([ + cap({ + id: 'a', + kind: 'skill', + name: 'review', + origin: 'core', + provider: 'core', + description: 'Review source code.', + conflictGroup: 'skill:review', + sourcePriority: 0, + }), + cap({ + id: 'b', + kind: 'skill', + name: 'review', + origin: 'plugin', + provider: 'plugin', + description: 'Review and rewrite source code.', + conflictGroup: 'skill:review', + sideEffects: ['writes_files'], + sourcePriority: 10, + }), + ]); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ + group: 'skill:review', + winner: 'a', + suppressed: ['b'], + severity: 'warn', + }); + expect(conflicts[0].suggestedAction).toContain('Choose one primary provider'); + }); + + it('downgrades equivalent duplicate providers to info', () => { + const conflicts = detectCapabilityConflicts([ + cap({ + id: 'a', + kind: 'skill', + name: 'setup', + origin: 'local', + provider: 'local', + description: 'Route setup requests.', + conflictGroup: 'skill:setup', + sourcePriority: 0, + }), + cap({ + id: 'b', + kind: 'skill', + name: 'setup', + origin: 'plugin', + provider: 'plugin', + description: 'Route setup requests.', + conflictGroup: 'skill:setup', + sourcePriority: 10, + }), + ]); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ + group: 'skill:setup', + winner: 'a', + suppressed: ['b'], + severity: 'info', + }); + expect(conflicts[0].suggestedAction).toContain('No action required'); + }); + + it('treats same-name providers with highly similar descriptions as equivalent', () => { + const conflicts = detectCapabilityConflicts([ + cap({ + id: 'a', + kind: 'skill', + name: 'frontend-design', + origin: 'core', + provider: 'core', + description: 'Create distinctive production-grade frontend interfaces with high design quality for web components and pages.', + sourcePriority: 0, + }), + cap({ + id: 'b', + kind: 'skill', + name: 'frontend-design', + origin: 'plugin', + provider: 'plugin', + description: 'Create distinctive production-grade frontend interfaces with high design quality for web components and applications.', + sourcePriority: 10, + }), + ]); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ + group: 'skill:frontend-design', + severity: 'info', + }); + expect(conflicts[0].suggestedAction).toContain('No action required'); + }); +}); diff --git a/test/embeddings/cache-rebuild.test.ts b/test/embeddings/cache-rebuild.test.ts index 19ab080..802cf12 100644 --- a/test/embeddings/cache-rebuild.test.ts +++ b/test/embeddings/cache-rebuild.test.ts @@ -77,11 +77,31 @@ describe('embedding cache status and rebuild', () => { expect(status.state).toBe('stale'); expect(status.covered).toBe(1); expect(status.active).toBe(2); + expect(status.coveragePercent).toBe(50); + expect(status.missingIds).toEqual(['b']); + }); + + it('treats an empty cache as ok when there are no active capabilities', async () => { + const { paths, cache } = await importWithTempConstants(tempDir); + mkdirSync(dirname(paths.indexPath), { recursive: true }); + writeFileSync(paths.indexPath, JSON.stringify([]), 'utf-8'); + writeFileSync(paths.binPath, Buffer.from(new Float32Array([]).buffer)); + writeFileSync(paths.statusPath, JSON.stringify({ indexed: 0, dim: 0 }), 'utf-8'); + + const status = cache.getEmbeddingCacheStatus([cap('disabled', 'disabled')]); + + expect(status.state).toBe('ok'); + expect(status.active).toBe(0); + expect(status.coveragePercent).toBe(100); }); it('rebuilds with temp files and atomic final cache', async () => { vi.doMock('../../src/embeddings/provider.js', () => ({ - getEmbeddingProviderConfig: (config: UserConfig) => config, + getEmbeddingProviderConfig: (config: UserConfig) => ({ + apiBase: config.embeddingApiBase, + apiKey: config.embeddingApiKey, + model: config.embeddingModel, + }), embedTexts: vi.fn(async (texts: string[]) => texts.map((_, index) => [index + 1, 0])), })); const { paths, rebuild } = await importWithTempConstants(tempDir); @@ -104,13 +124,99 @@ describe('embedding cache status and rebuild', () => { expect(result.ok).toBe(true); expect(result.indexed).toBe(2); + expect(result.embedded).toBe(2); + expect(result.reused).toBe(0); expect(JSON.parse(readFileSync(paths.indexPath, 'utf-8'))).toEqual(['a', 'b']); + const statusFile = JSON.parse(readFileSync(paths.statusPath, 'utf-8')) as { entries?: Record; provider?: string; model?: string }; + expect(statusFile.provider).toBe('https://example.test/v*'); + expect(statusFile.model).toBe('fake-model'); + expect(statusFile.entries).toHaveProperty('a'); expect(existsSync(paths.lockPath)).toBe(false); }); + it('embeds only new or changed nodes during incremental rebuild', async () => { + const embedTexts = vi.fn(async (texts: string[]) => texts.map((_, index) => [embedTexts.mock.calls.length, index + 1])); + vi.doMock('../../src/embeddings/provider.js', () => ({ + getEmbeddingProviderConfig: (config: UserConfig) => ({ + apiBase: config.embeddingApiBase, + apiKey: config.embeddingApiKey, + model: config.embeddingModel, + }), + embedTexts, + })); + const { paths, rebuild } = await importWithTempConstants(tempDir); + const config: UserConfig = { + compileModel: 'x', + aliases: {}, + scanPaths: [], + mode: 'ask', + autoThreshold: 0.85, + engine: 'tag', + strategy: 'ask', + externalDiscovery: false, + platform: 'claude-code', + language: 'auto', + embeddingApiBase: 'https://example.test/v1', + embeddingApiKey: 'fake-key', + embeddingModel: 'fake-model', + }; + + const first = await rebuild.rebuildEmbeddingCache([cap('a'), cap('b')], config); + const second = await rebuild.rebuildEmbeddingCache([cap('a'), cap('b'), cap('c')], config); + + expect(first.embedded).toBe(2); + expect(second.ok).toBe(true); + expect(second.embedded).toBe(1); + expect(second.reused).toBe(2); + expect(second.removed).toBe(0); + expect(JSON.parse(readFileSync(paths.indexPath, 'utf-8'))).toEqual(['a', 'b', 'c']); + expect(embedTexts).toHaveBeenCalledTimes(2); + }); + + it('removes all vectors when every capability is disabled', async () => { + const embedTexts = vi.fn(async (texts: string[]) => texts.map((_, index) => [index + 1, 0])); + vi.doMock('../../src/embeddings/provider.js', () => ({ + getEmbeddingProviderConfig: (config: UserConfig) => ({ + apiBase: config.embeddingApiBase, + apiKey: config.embeddingApiKey, + model: config.embeddingModel, + }), + embedTexts, + })); + const { paths, rebuild } = await importWithTempConstants(tempDir); + const config: UserConfig = { + compileModel: 'x', + aliases: {}, + scanPaths: [], + mode: 'ask', + autoThreshold: 0.85, + engine: 'tag', + strategy: 'ask', + externalDiscovery: false, + platform: 'claude-code', + language: 'auto', + embeddingApiBase: 'https://example.test/v1', + embeddingApiKey: 'fake-key', + embeddingModel: 'fake-model', + }; + + await rebuild.rebuildEmbeddingCache([cap('a'), cap('b')], config); + const removed = await rebuild.rebuildEmbeddingCache([cap('a', 'disabled'), cap('b', 'disabled')], config); + + expect(removed.ok).toBe(true); + expect(removed.removed).toBe(2); + expect(removed.indexed).toBe(0); + expect(removed.status.state).toBe('ok'); + expect(JSON.parse(readFileSync(paths.indexPath, 'utf-8'))).toEqual([]); + }); + it('keeps the old cache when rebuild fails', async () => { vi.doMock('../../src/embeddings/provider.js', () => ({ - getEmbeddingProviderConfig: (config: UserConfig) => config, + getEmbeddingProviderConfig: (config: UserConfig) => ({ + apiBase: config.embeddingApiBase, + apiKey: config.embeddingApiKey, + model: config.embeddingModel, + }), embedTexts: vi.fn(async () => { throw new Error('provider failed'); }), diff --git a/test/fixtures/.skillshub/test-ecc-skill/SKILL.md b/test/fixtures/.skillshub/test-ecc-skill/SKILL.md new file mode 100644 index 0000000..bdaf5a6 --- /dev/null +++ b/test/fixtures/.skillshub/test-ecc-skill/SKILL.md @@ -0,0 +1,9 @@ +--- +name: test-ecc-skill +description: A fixture skill stored under a skillshub-style root. +origin: ECC +--- + +# Test ECC Skill + +Used to verify that LazyBrain scans top-level `.skillshub` style skill roots. diff --git a/test/graph/graph.test.ts b/test/graph/graph.test.ts new file mode 100644 index 0000000..c74286f --- /dev/null +++ b/test/graph/graph.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { Graph } from '../../src/graph/graph.js'; + +describe('Graph load/save', () => { + it('preserves capability governance and conflict metadata', () => { + const dir = mkdtempSync(join(tmpdir(), 'lazybrain-graph-')); + const graphPath = join(dir, 'graph.json'); + + try { + const graph = new Graph(); + graph.addNode({ + id: 'danger-release', + kind: 'skill', + name: 'danger-release', + description: 'Publishes production releases.', + origin: 'plugin', + provider: 'plugin', + conflictGroup: 'skill:release', + sideEffects: ['publishes', 'destructive'], + status: 'installed', + compatibility: ['codex'], + filePath: '/tmp/danger-release/SKILL.md', + tags: ['release'], + exampleQueries: ['publish release'], + category: 'release', + explanation_template: '{tool_name} handles release work.', + triggers: ['release'], + aliases: ['ship'], + tier: 0, + meta: { version: '1.0.0' }, + evolvedTags: ['ship'], + costLevel: 'high', + riskLevel: 'destructive', + requiresConfirmation: true, + hiddenByDefault: true, + sourcePriority: 3, + overlapsWith: ['release'], + }); + + graph.save(graphPath); + const loaded = Graph.load(graphPath).getNode('danger-release'); + + expect(loaded).toMatchObject({ + provider: 'plugin', + conflictGroup: 'skill:release', + sideEffects: ['publishes', 'destructive'], + explanation_template: '{tool_name} handles release work.', + triggers: ['release'], + aliases: ['ship'], + tier: 0, + meta: { version: '1.0.0' }, + evolvedTags: ['ship'], + costLevel: 'high', + riskLevel: 'destructive', + requiresConfirmation: true, + hiddenByDefault: true, + sourcePriority: 3, + overlapsWith: ['release'], + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/health/api-test.test.ts b/test/health/api-test.test.ts index 7ccf094..b6a2afc 100644 --- a/test/health/api-test.test.ts +++ b/test/health/api-test.test.ts @@ -36,6 +36,8 @@ describe('runApiTests', () => { expect(report.ok).toBe(true); expect(report.results.find(result => result.target === 'embedding')?.dim).toBe(3); + expect(report.results.every(result => typeof result.latencyMs === 'number')).toBe(true); + expect(report.results.every(result => typeof result.lastCheckedAt === 'string')).toBe(true); expect(JSON.stringify(report)).not.toContain('private-compile-key'); expect(JSON.stringify(report)).not.toContain('private-secretary-key'); expect(JSON.stringify(report)).not.toContain('private-embedding-key'); @@ -55,6 +57,23 @@ describe('runApiTests', () => { expect(report.results[0].error).toContain('unauthorized'); }); + it('redacts secrets echoed by provider errors', async () => { + const leakedToken = ['sk', 'leaked123456'].join('-'); + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: false, + status: 401, + text: async () => `Bearer ${leakedToken} api_key=very-secret token: also-secret`, + }))); + + const report = await runApiTests({ ...DEFAULT_CONFIG, compileApiKey: 'fake-key' }, ['compile']); + const serialized = JSON.stringify(report); + + expect(serialized).not.toContain(leakedToken); + expect(serialized).not.toContain('very-secret'); + expect(serialized).not.toContain('also-secret'); + expect(serialized).toContain('[redacted]'); + }); + it('reports timeout-style fetch errors', async () => { vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('timeout'); diff --git a/test/hook/backup.test.ts b/test/hook/backup.test.ts index 911dfbb..90c74d1 100644 --- a/test/hook/backup.test.ts +++ b/test/hook/backup.test.ts @@ -17,15 +17,18 @@ describe('hook backup', () => { it('creates backup and restores previous settings', () => { const settingsPath = join(tempDir, '.claude', 'settings.json'); + const hooksPath = join(tempDir, '.claude', 'hooks', 'hooks.json'); const chainPath = join(tempDir, '.claude', 'lazybrain-statusline-chain.json'); const mapPath = join(tempDir, '.lazybrain', 'hook-install-map.json'); const legacyPath = join(tempDir, '.lazybrain', 'hook-install.json'); - mkdirSync(join(tempDir, '.claude'), { recursive: true }); + mkdirSync(join(tempDir, '.claude', 'hooks'), { recursive: true }); writeFileSync(settingsPath, '{"before":true}', { encoding: 'utf-8', flag: 'w' }); + writeFileSync(hooksPath, '{"hooks":{"before":true}}', { encoding: 'utf-8', flag: 'w' }); const backup = createHookBackup({ scope: 'project', settingsPath, + hooksPath, statuslineChainPath: chainPath, installStateMapPath: mapPath, legacyInstallStatePath: legacyPath, @@ -33,14 +36,17 @@ describe('hook backup', () => { }); writeFileSync(settingsPath, '{"after":true}', 'utf-8'); + writeFileSync(hooksPath, '{"hooks":{"after":true}}', 'utf-8'); restoreHookBackup(settingsPath, backup); expect(readFileSync(settingsPath, 'utf-8')).toBe('{"before":true}'); + expect(readFileSync(hooksPath, 'utf-8')).toBe('{"hooks":{"before":true}}'); expect(findHookBackup(settingsPath, backup.id)?.id).toBe(backup.id); }); it('removes files that did not exist at backup time', () => { const settingsPath = join(tempDir, '.claude', 'settings.json'); + const hooksPath = join(tempDir, '.claude', 'hooks', 'hooks.json'); const chainPath = join(tempDir, '.claude', 'lazybrain-statusline-chain.json'); const mapPath = join(tempDir, '.lazybrain', 'hook-install-map.json'); const legacyPath = join(tempDir, '.lazybrain', 'hook-install.json'); @@ -48,20 +54,25 @@ describe('hook backup', () => { const backup = createHookBackup({ scope: 'project', settingsPath, + hooksPath, statuslineChainPath: chainPath, installStateMapPath: mapPath, legacyInstallStatePath: legacyPath, now: new Date('2026-04-25T00:00:00.000Z'), }); + mkdirSync(join(tempDir, '.claude', 'hooks'), { recursive: true }); writeFileSync(settingsPath, '{"after":true}', 'utf-8'); + writeFileSync(hooksPath, '{"hooks":{"after":true}}', 'utf-8'); restoreHookBackup(settingsPath, backup); expect(existsSync(settingsPath)).toBe(false); + expect(existsSync(hooksPath)).toBe(false); }); it('finds a specific backup by timestamp id', () => { const settingsPath = join(tempDir, '.claude', 'settings.json'); + const hooksPath = join(tempDir, '.claude', 'hooks', 'hooks.json'); const chainPath = join(tempDir, '.claude', 'lazybrain-statusline-chain.json'); const mapPath = join(tempDir, '.lazybrain', 'hook-install-map.json'); const legacyPath = join(tempDir, '.lazybrain', 'hook-install.json'); @@ -69,6 +80,7 @@ describe('hook backup', () => { const first = createHookBackup({ scope: 'project', settingsPath, + hooksPath, statuslineChainPath: chainPath, installStateMapPath: mapPath, legacyInstallStatePath: legacyPath, @@ -77,6 +89,7 @@ describe('hook backup', () => { const second = createHookBackup({ scope: 'project', settingsPath, + hooksPath, statuslineChainPath: chainPath, installStateMapPath: mapPath, legacyInstallStatePath: legacyPath, diff --git a/test/hook/plan.test.ts b/test/hook/plan.test.ts index 6190f3a..e8b4593 100644 --- a/test/hook/plan.test.ts +++ b/test/hook/plan.test.ts @@ -63,6 +63,29 @@ describe('buildHookPlan', () => { expect(plan.statusline.inheritedCommand).toContain('third-party-hud'); }); + it('plans project-visible combined statusline when LazyBrain is inherited globally', () => { + const plan = buildHookPlan({ + ...base, + settings: {}, + globalSettings: { + statusLine: { type: 'command', command: 'node /repo/lazybrain/dist/bin/statusline-combined.js' }, + }, + shouldInstallStatusline: true, + }); + expect(plan.statusline.mode).toBe('combine'); + expect(plan.statusline.plannedCommand).toContain('statusline-combined.js'); + }); + + it('plans LazyBrain statusline when statusline install is enabled and no HUD exists', () => { + const plan = buildHookPlan({ + ...base, + settings: {}, + shouldInstallStatusline: true, + }); + expect(plan.statusline.mode).toBe('lazybrain'); + expect(plan.statusline.plannedCommand).toContain('statusline.js'); + }); + it('plans to combine existing project HUD instead of replacing it', () => { const plan = buildHookPlan({ ...base, diff --git a/test/hook/readiness.test.ts b/test/hook/readiness.test.ts index 5dc33d6..3ed45b9 100644 --- a/test/hook/readiness.test.ts +++ b/test/hook/readiness.test.ts @@ -39,6 +39,15 @@ describe('evaluateReady', () => { expect(report.blockers.join('\n')).toContain('Graph missing'); }); + it('reports NOT_READY when the graph persisted compile errors', () => { + const report = evaluateReady({ + ...base, + compileErrors: ['relation_invalid_type:source->target: blocks'], + }); + expect(report.state).toBe('NOT_READY'); + expect(report.blockers.join('\n')).toContain('Graph has 1 compile errors'); + }); + it('reports NOT_READY when LazyBrain remains in Stop', () => { const report = evaluateReady({ ...base, @@ -58,6 +67,32 @@ describe('evaluateReady', () => { expect(report.blockers.join('\n')).toContain('project settings still contains LazyBrain Stop hook'); }); + it('reports NOT_READY when LazyBrain UserPromptSubmit is duplicated', () => { + const aliasProject = ['lazy', 'user'].join('_'); + const report = evaluateReady({ + ...base, + scopes: [ + { + ...base.scopes[0], + settings: { + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: 'node /repo/lazybrain/dist/bin/hook.js' }] }, + { hooks: [{ type: 'command', command: `node /repo/${aliasProject}/dist/bin/hook.js` }] }, + ], + }, + }, + }, + base.scopes[1], + ], + }); + expect(report.state).toBe('NOT_READY'); + expect(report.scopes[0].lazybrainUserPromptSubmitCount).toBe(2); + expect(report.scopes[0].duplicateLazyBrainUserPromptSubmit).toBe(true); + expect(report.blockers.join('\n')).toContain('duplicate LazyBrain UserPromptSubmit hooks (2)'); + }); + + it('warns when project LazyBrain statusline would hide global HUD', () => { const report = evaluateReady({ ...base, @@ -76,6 +111,32 @@ describe('evaluateReady', () => { expect(report.warnings.join('\n')).toContain('may hide the global HUD'); }); + it('warns when LazyBrain hook is installed without visible statusline', () => { + const report = evaluateReady({ + ...base, + scopes: [ + { + ...base.scopes[0], + settings: { + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'node /repo/lazybrain/dist/bin/hook.js' }] }], + }, + }, + installState: { + scope: 'project', + workspaceRoot: '/repo/project', + hookCommand: 'node /repo/lazybrain/dist/bin/hook.js', + installedAt: '2026-04-25T00:00:00.000Z', + statuslineMode: 'none', + }, + }, + base.scopes[1], + ], + }); + expect(report.state).toBe('READY'); + expect(report.warnings.join('\n')).toContain('statusline/HUD is not visible'); + }); + it('reports NOT_READY when hook breaker is open', () => { const report = evaluateReady({ ...base, @@ -129,6 +190,27 @@ describe('evaluateReady', () => { expect(report.blockers.join('\n')).toContain('Host load average is high'); }); + it('can ignore current host load for release readiness checks', () => { + const report = evaluateReady({ + ...base, + loadAverage1m: 12, + ignoreLoadAverage: true, + config: { + engine: 'tag', + hookSafety: { + maxConcurrentHooks: 3, + staleHookMs: 15000, + avgDurationBreakerMs: 3000, + loadAvgBreaker: 8, + breakerCooldownMs: 60000, + recentDurationsWindow: 12, + }, + }, + }); + expect(report.state).toBe('READY'); + expect(report.blockers.join('\n')).not.toContain('Host load average is high'); + }); + it('keeps project and global reports separate', () => { const report = evaluateReady({ ...base, diff --git a/test/hook/settings.test.ts b/test/hook/settings.test.ts index db658c1..d04aadb 100644 --- a/test/hook/settings.test.ts +++ b/test/hook/settings.test.ts @@ -8,11 +8,23 @@ import { describe('hook settings', () => { it('recognizes both legacy and built dist hook commands', () => { + const underscoredRepoName = ['lazy', 'user'].join('_'); expect(isLazyBrainHookCommand('node /tmp/lazybrain/dist/bin/hook.js')).toBe(true); expect(isLazyBrainHookCommand('node /tmp/lazybrain/bin/hook.js')).toBe(true); + expect(isLazyBrainHookCommand('node "/tmp/lazy-brain/dist/bin/hook.js"')).toBe(true); + expect(isLazyBrainHookCommand("node '/tmp/lazy_brain/dist/bin/hook.js'")).toBe(true); + expect(isLazyBrainHookCommand(`node /tmp/${underscoredRepoName}/dist/bin/hook.js`)).toBe(true); expect(isLazyBrainHookCommand('python3 ~/.claude/hooks/codeisland-state.py')).toBe(false); }); + it('does not match similarly named hook paths', () => { + const underscoredPrefix = ['lazy', 'user'].join('_'); + expect(isLazyBrainHookCommand(`node /tmp/${underscoredPrefix}land/dist/bin/hook.js`)).toBe(false); + expect(isLazyBrainHookCommand('node /tmp/lazybrain-tools/dist/bin/hook.js')).toBe(false); + expect(isLazyBrainHookCommand('node /tmp/notlazybrain/dist/bin/hook.js')).toBe(false); + expect(isLazyBrainHookCommand('node /tmp/lazybrain/dist/bin/not-hook.js')).toBe(false); + }); + it('installs only UserPromptSubmit and removes stale Stop entries', () => { const settings = { hooks: { diff --git a/test/hook/status.test.ts b/test/hook/status.test.ts index 57949e1..60f94d2 100644 --- a/test/hook/status.test.ts +++ b/test/hook/status.test.ts @@ -27,10 +27,37 @@ describe('hook lifecycle status', () => { expect(status.lazybrainUserPromptSubmit).toBe(true); expect(status.lazybrainStop).toBe(false); + expect(status.lazybrainUserPromptSubmitCount).toBe(1); + expect(status.duplicateLazyBrainUserPromptSubmit).toBe(false); expect(status.stopCommands).toHaveLength(2); expect(status.avgDurationMs).toBe(150); }); + it('counts duplicate LazyBrain UserPromptSubmit registrations', () => { + const aliasProject = ['lazy', 'user'].join('_'); + const status = getHookLifecycleStatus({ + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: 'node /tmp/lazybrain/dist/bin/hook.js' }] }, + { hooks: [{ type: 'command', command: `node /tmp/${aliasProject}/dist/bin/hook.js` }] }, + ], + }, + }, { + installState: null, + runtime: { + activeRuns: [], + hungRuns: [], + staleRuns: [], + health: { recentDurationsMs: [], updatedAt: 1000 }, + }, + now: 1000, + }); + + expect(status.lazybrainUserPromptSubmit).toBe(true); + expect(status.lazybrainUserPromptSubmitCount).toBe(2); + expect(status.duplicateLazyBrainUserPromptSubmit).toBe(true); + }); + it('detects stale LazyBrain Stop registration', () => { const status = getHookLifecycleStatus({ hooks: { diff --git a/test/integrations/gitnexus.test.ts b/test/integrations/gitnexus.test.ts new file mode 100644 index 0000000..2f3aa20 --- /dev/null +++ b/test/integrations/gitnexus.test.ts @@ -0,0 +1,61 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { getGitNexusStatus } from '../../src/integrations/gitnexus.js'; + +function makeGitRepo(): { dir: string; commit: string } { + const dir = mkdtempSync(join(tmpdir(), 'lazybrain-gitnexus-')); + execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: dir, stdio: 'ignore' }); + writeFileSync(join(dir, 'README.md'), '# test\n', 'utf-8'); + execFileSync('git', ['add', 'README.md'], { cwd: dir, stdio: 'ignore' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: dir, stdio: 'ignore' }); + const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf-8' }).trim(); + return { dir, commit }; +} + +describe('GitNexus local status', () => { + it('reports a current local index without requiring MCP', () => { + const { dir, commit } = makeGitRepo(); + try { + mkdirSync(join(dir, '.gitnexus')); + writeFileSync(join(dir, '.gitnexus', 'meta.json'), JSON.stringify({ + repoPath: dir, + lastCommit: commit, + indexedAt: '2026-05-05T10:03:19.834Z', + stats: { files: 1, nodes: 2, edges: 3, processes: 4, embeddings: 5 }, + }), 'utf-8'); + + const status = getGitNexusStatus(dir); + expect(status.available).toBe(true); + expect(status.mcpRequired).toBe(false); + expect(status.state).toBe('current'); + expect(status.stale).toBe(false); + expect(status.currentCommit).toBe(commit); + expect(status.stats?.nodes).toBe(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('reports stale and invalid local index states', () => { + const { dir } = makeGitRepo(); + try { + mkdirSync(join(dir, '.gitnexus')); + writeFileSync(join(dir, '.gitnexus', 'meta.json'), JSON.stringify({ + repoPath: dir, + lastCommit: '0000000000000000000000000000000000000000', + indexedAt: '2026-05-05T10:03:19.834Z', + }), 'utf-8'); + expect(getGitNexusStatus(dir).state).toBe('stale'); + + writeFileSync(join(dir, '.gitnexus', 'meta.json'), '{bad json', 'utf-8'); + expect(getGitNexusStatus(dir).state).toBe('invalid'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/matcher/semantic-engine.test.ts b/test/matcher/semantic-engine.test.ts index e0256d5..8a25d76 100644 --- a/test/matcher/semantic-engine.test.ts +++ b/test/matcher/semantic-engine.test.ts @@ -123,4 +123,50 @@ describe('semantic match engine', () => { expect(result.matches).toHaveLength(0); expect(result.warnings?.join('\n')).toContain('dimension mismatch'); }); + + it('uses covered semantic rows with a warning when embedding cache is partial', async () => { + const indexPath = join(tempDir, 'graph.embeddings.index.json'); + const binPath = join(tempDir, 'graph.embeddings.bin'); + writeFileSync(indexPath, JSON.stringify(['cap-a']), 'utf-8'); + writeFileSync(binPath, Buffer.from(new Float32Array([1, 0]).buffer)); + + vi.doMock('../../src/constants.js', async () => { + const actual = await vi.importActual('../../src/constants.js'); + return { + ...actual, + EMBEDDINGS_INDEX_PATH: indexPath, + EMBEDDINGS_BIN_PATH: binPath, + }; + }); + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ embedding: [1, 0] }] }), + }))); + + const { Graph } = await import('../../src/graph/graph.js'); + const { match } = await import('../../src/matcher/matcher.js'); + const graph = new Graph(); + graph.addNode(makeCap('cap-a', 'alpha')); + graph.addNode(makeCap('cap-b', 'beta')); + + const config: UserConfig = { + aliases: {}, + scanPaths: [], + mode: 'ask', + autoThreshold: 0.85, + engine: 'semantic', + strategy: 'ask', + embeddingApiBase: 'https://example.test/v1', + embeddingApiKey: 'test-key', + embeddingModel: 'test-embedding', + platform: 'claude-code', + platforms: {}, + }; + + const result = await match('find alpha', { graph, config }); + + expect(result.matches[0].capability.name).toBe('alpha'); + expect(result.matches[0].layer).toBe('semantic'); + expect(result.warnings?.join('\n')).toContain('partial embedding cache'); + }); }); diff --git a/test/matcher/tag-layer.test.ts b/test/matcher/tag-layer.test.ts index 035a882..73668a1 100644 --- a/test/matcher/tag-layer.test.ts +++ b/test/matcher/tag-layer.test.ts @@ -186,7 +186,9 @@ describe('tagMatch', () => { it('expands unit test phrasing into test coverage and tdd tokens', () => { const tokens = tokenize('add unit tests'); - expect(tokens).toEqual(expect.arrayContaining(['test-coverage', 'tdd', 'cpp-test'])); + expect(tokens).toEqual(expect.arrayContaining(['test-coverage', 'tdd', 'tdd-workflow', 'test-engineer'])); + expect(tokens).not.toContain('cpp-test'); + expect(tokens).not.toContain('flutter-test'); }); it('expands commit phrasing into git commit capability names', () => { @@ -214,6 +216,244 @@ describe('tagMatch', () => { expect(results[0]?.capability.name).toBe('Software Architect'); }); + it('prefers AI slop cleaner over generic simplification for AI-generated slop', () => { + const generic = cap({ + id: '15', + name: 'code-simplifier', + tags: ['code-cleanup', 'refactor', 'code-quality'], + exampleQueries: ['clean up code', 'simplify code'], + description: 'Simplifies and refactors code for maintainability.', + }); + const slop = cap({ + id: '16', + name: 'ai-slop-cleaner', + tags: ['ai-generated-code', 'code-cleanup', 'slop'], + exampleQueries: ['clean up AI generated code'], + description: 'Remove low-quality AI-generated code while preserving behavior.', + }); + + const results = tagMatch('枅理 AI 生成的垃土代码', [generic, slop], 'claude-code', 3); + expect(results[0]?.capability.name).toBe('ai-slop-cleaner'); + }); + + it('prefers database specialists over broad backend workflows for database migration', () => { + const broad = cap({ + id: '17', + name: 'multi-backend', + category: 'development', + tags: ['backend', 'development', 'planning', 'optimization'], + exampleQueries: ['backend development workflow', 'database backend planning'], + description: 'Structured end-to-end backend workflow.', + }); + const database = cap({ + id: '18', + name: 'Database Optimizer', + category: 'data', + tags: ['database', 'schema', 'migration', 'postgres'], + exampleQueries: ['database migration', 'design database schemas'], + description: 'Design database schemas and tune database performance.', + }); + + const results = tagMatch('database migration', [broad, database], 'claude-code', 3); + expect(results[0]?.capability.name).toBe('Database Optimizer'); + }); + + it('keeps database migration above memory and broad workflow matches', () => { + const memory = cap({ + id: '29', + name: 'mem-search', + category: 'development', + tags: ['database', 'migration'], + exampleQueries: ['database migration'], + description: 'Search previous session memory.', + }); + const database = cap({ + id: '30', + name: 'Database Optimizer', + category: 'data', + tags: ['database', 'schema', 'postgres'], + exampleQueries: ['database migration'], + description: 'Design database schemas and tune database performance.', + }); + + const results = tagMatch('database migration', [memory, database], 'claude-code', 3); + expect(results[0]?.capability.name).toBe('Database Optimizer'); + }); + + it('breaks capped score ties using specialized intent priority', () => { + const broad = cap({ + id: '21', + name: 'make-plan', + category: 'planning', + tags: ['api', 'documentation', 'planning'], + exampleQueries: ['generate api documentation'], + description: 'Plan a complex task before executing it.', + }); + const writer = cap({ + id: '22', + name: 'Technical Writer', + category: 'content', + tags: ['api', 'documentation', 'writer'], + exampleQueries: ['generate api documentation'], + description: 'Write API docs and developer documentation.', + }); + + const results = tagMatch('生成 API 文档', [broad, writer], 'claude-code', 3); + expect(results[0]?.capability.name).toBe('Technical Writer'); + }); + + it('routes production deploy wording toward product/frontend release capabilities', () => { + const setup = cap({ + id: '23', + name: 'setup', + category: 'operations', + tags: ['deployment', 'production'], + exampleQueries: ['deploy to production'], + description: 'Install and configure tools.', + }); + const product = cap({ + id: '24', + name: 'product-capability', + category: 'product', + tags: ['product', 'release'], + exampleQueries: ['deploy to production'], + description: 'Shape production product capability choices.', + }); + + const results = tagMatch('deploy to production', [setup, product], 'claude-code', 3); + expect(results[0]?.capability.name).toBe('product-capability'); + }); + + it('routes Chinese production deploy wording toward setup and verification capabilities', () => { + const product = cap({ + id: '27', + name: 'product-capability', + category: 'product', + tags: ['product', 'release'], + exampleQueries: ['deploy to production'], + description: 'Shape production product capability choices.', + }); + const verify = cap({ + id: '28', + name: 'verification-loop', + category: 'deployment', + tags: ['deployment', 'production', 'verification'], + exampleQueries: ['郚眲到生产环境'], + description: 'Verify production deployment readiness.', + }); + + const results = tagMatch('郚眲到生产环境', [product, verify], 'claude-code', 3); + expect(results[0]?.capability.name).toBe('verification-loop'); + }); + + it('routes backend refactor wording toward backend/refactor specialists', () => { + const broad = cap({ + id: '25', + name: 'multi-backend', + category: 'development', + tags: ['backend', 'workflow', 'refactor'], + exampleQueries: ['refactor backend'], + description: 'Broad backend workflow.', + }); + const backend = cap({ + id: '26', + name: 'backend-patterns', + category: 'development', + tags: ['backend', 'architecture', 'refactor'], + exampleQueries: ['refactor backend'], + description: 'Backend architecture patterns and refactor guidance.', + }); + + const results = tagMatch('垮我重构敎䞪后端', [broad, backend], 'claude-code', 3); + expect(results[0]?.capability.name).toBe('backend-patterns'); + }); + + it('routes generic Python development toward Python-specific capabilities', () => { + const writer = cap({ + id: '31', + name: 'khazix-writer', + category: 'content', + tags: ['code', 'development'], + exampleQueries: ['写 Python 代码'], + description: 'Generate explanatory writing for a codebase.', + }); + const python = cap({ + id: '32', + name: 'python-review', + category: 'code-quality', + tags: ['python', 'code', 'review'], + exampleQueries: ['写 Python 代码'], + description: 'Review Python code for Pythonic idioms.', + }); + + const results = tagMatch('写 Python 代码', [writer, python], 'claude-code', 3); + expect(results[0]?.capability.name).toBe('python-review'); + }); + + it('routes generic Rust development toward Rust review/build capabilities', () => { + const test = cap({ + id: '33', + name: 'rust-test', + category: 'testing', + tags: ['rust', 'test'], + exampleQueries: ['Rust 匀发'], + description: 'Write Rust tests first.', + }); + const review = cap({ + id: '34', + name: 'rust-review', + category: 'code-quality', + tags: ['rust', 'review'], + exampleQueries: ['Rust 匀发'], + description: 'Review idiomatic Rust code.', + }); + + const results = tagMatch('Rust 匀发', [test, review], 'claude-code', 3); + expect(results[0]?.capability.name).toBe('rust-review'); + }); + + it('routes frontend UI component wording toward frontend design specialists', () => { + const dev = cap({ + id: '35', + name: 'frontend-dev', + category: 'development', + tags: ['frontend', 'ui', 'component'], + exampleQueries: ['frontend UI component'], + description: 'Build frontend components.', + }); + const design = cap({ + id: '36', + name: 'frontend-design', + category: 'design', + tags: ['frontend', 'ui', 'component'], + exampleQueries: ['frontend UI component'], + description: 'Build web components where visual design quality matters.', + }); + + const results = tagMatch('frontend UI component', [dev, design], 'claude-code', 3); + expect(results[0]?.capability.name).toBe('frontend-design'); + }); + + it('uses name coverage to break code-review ties toward code-specific reviewers', () => { + const critic = cap({ + id: '19', + name: 'critic', + tags: ['code', 'review'], + exampleQueries: ['code review'], + description: 'Broad work plan and code review expert.', + }); + const codeReviewer = cap({ + id: '20', + name: 'Code Reviewer', + tags: ['code', 'review'], + exampleQueries: ['code review'], + description: 'Focused code reviewer.', + }); + + const results = tagMatch('code review', [critic, codeReviewer], 'claude-code', 3); + expect(results[0]?.capability.name).toBe('Code Reviewer'); + }); + it('layer is always "tag"', () => { const results = tagMatch('code review', caps, 'claude-code', 3); for (const r of results) { diff --git a/test/matcher/thinking-trigger.test.ts b/test/matcher/thinking-trigger.test.ts index 74e2f81..bb5fb5a 100644 --- a/test/matcher/thinking-trigger.test.ts +++ b/test/matcher/thinking-trigger.test.ts @@ -58,6 +58,7 @@ describe('detectThinkingNeed', () => { const hint = detectThinkingNeed('䜠觉埗这䞪架构怎么样'); expect(hint.triggered).toBe(true); expect(hint.suggestedSkills.some(s => s.name === 'critic')).toBe(true); + expect(hint.suggestedSkills.some(s => s.name === 'council')).toBe(true); }); it('triggers critic for 觉埗 at start', () => { diff --git a/test/mcp/server.test.ts b/test/mcp/server.test.ts index a19869e..c37c7eb 100644 --- a/test/mcp/server.test.ts +++ b/test/mcp/server.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; import { Graph } from '../../src/graph/graph.js'; import { DEFAULT_CONFIG } from '../../src/constants.js'; -import { handleMcpRequest } from '../../src/mcp/server.js'; +import { formatMcpWireResponse, handleMcpRequest } from '../../src/mcp/server.js'; +import { MCP_TOOL_DEFINITIONS } from '../../src/mcp/tools.js'; import type { Capability } from '../../src/types.js'; function cap(overrides: Partial & Pick): Capability { @@ -44,7 +45,23 @@ function toolContentText(response: Record): string { return result.content?.[0]?.text ?? ''; } +function toolPayload(response: Record): Record { + return JSON.parse(toolContentText(response)) as Record; +} + describe('MCP server', () => { + it('mirrors newline JSON transport for Claude Code health checks', () => { + const response = formatMcpWireResponse({ jsonrpc: '2.0', id: 1, result: { ok: true } }, false); + expect(response).toBe('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n'); + expect(response).not.toContain('Content-Length'); + }); + + it('keeps Content-Length framing for framed MCP clients', () => { + const response = formatMcpWireResponse({ jsonrpc: '2.0', id: 1, result: { ok: true } }, true); + expect(response).toMatch(/^Content-Length: \d+\r\n\r\n/); + expect(response).toContain('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}'); + }); + it('initializes and lists LazyBrain tools', async () => { const init = resultOf(await handleMcpRequest({ jsonrpc: '2.0', id: 1, method: 'initialize' }, ctx())); expect(JSON.stringify(init)).toContain('lazybrain'); @@ -52,6 +69,15 @@ describe('MCP server', () => { const list = resultOf(await handleMcpRequest({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, ctx())); expect(JSON.stringify(list)).toContain('lazybrain.route'); expect(JSON.stringify(list)).toContain('Call lazybrain.route before non-trivial coding'); + expect((list.result as { tools: unknown[] }).tools).toEqual(MCP_TOOL_DEFINITIONS.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: { + type: tool.inputSchema.type, + properties: { ...tool.inputSchema.properties }, + ...(tool.inputSchema.required ? { required: [...tool.inputSchema.required] } : {}), + }, + }))); }); it('returns RouteSpec through lazybrain.route', async () => { @@ -62,9 +88,32 @@ describe('MCP server', () => { params: { name: 'lazybrain.route', arguments: { query: 'review code for regressions', target: 'codex' } }, }, ctx())); const text = toolContentText(response); - expect(text).toContain('"schemaVersion": "1.4.5"'); + expect(text).toContain('"schemaVersion": "1.5.0"'); expect(text).toContain('"target": "codex"'); + expect(text).toContain('"choices"'); expect(text).not.toContain('/tmp/example-agent'); + const payload = toolPayload(response); + expect(payload.status).toBe('success'); + expect(payload).toHaveProperty('summary'); + expect(payload).toHaveProperty('next_actions'); + expect(payload).toHaveProperty('artifacts'); + expect(payload).toHaveProperty('choices'); + expect((payload.choices as Record).recommended).toBeTruthy(); + expect((payload.data as Record).schemaVersion).toBe('1.5.0'); + }); + + it('returns combo entry metadata through lazybrain.route', async () => { + const response = resultOf(await handleMcpRequest({ + jsonrpc: '2.0', + id: 31, + method: 'tools/call', + params: { name: 'lazybrain.route', arguments: { query: '检查讀证权限和密钥泄挏安党风险', target: 'codex' } }, + }, ctx())); + const text = toolContentText(response); + expect(text).toContain('"combo": "audit_security"'); + expect(text).toContain('"entryCommand"'); + expect(text).toContain('"executionMode"'); + expect(text).toContain('"modelStrategy"'); }); it('returns compact skill cards without local file paths', async () => { @@ -78,6 +127,9 @@ describe('MCP server', () => { expect(text).toContain('code-review'); expect(text).not.toContain('/tmp/example-agent'); expect(text).not.toContain('filePath'); + const payload = toolPayload(response); + expect(payload.status).toBe('success'); + expect(payload.artifacts).toEqual(['capability:review']); }); it('rejects oversized route queries', async () => { @@ -87,6 +139,12 @@ describe('MCP server', () => { method: 'tools/call', params: { name: 'lazybrain.route', arguments: { query: 'x'.repeat(2001) } }, }, ctx())); + expect((response.result as { isError?: boolean }).isError).toBe(true); expect(JSON.stringify(response)).toContain('Query is too long'); + const payload = toolPayload(response); + expect(payload.status).toBe('error'); + expect(payload).toHaveProperty('next_actions'); + expect(payload).toHaveProperty('error'); + expect(JSON.stringify(payload)).toContain('safe_retry'); }); }); diff --git a/test/orchestrator/route-dogfood.test.ts b/test/orchestrator/route-dogfood.test.ts new file mode 100644 index 0000000..032ffab --- /dev/null +++ b/test/orchestrator/route-dogfood.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { Graph } from '../../src/graph/graph.js'; +import { buildRouteSpec } from '../../src/orchestrator/route.js'; +import { DEFAULT_CONFIG } from '../../src/constants.js'; +import type { Capability } from '../../src/types.js'; +import { DOGFOOD_ROUTE_CASES } from '../../src/orchestrator/route-dogfood-cases.js'; + +function cap(name: string, tags: string[], description = `${name} capability`): Capability { + return { + id: name, + kind: 'skill', + name, + description, + origin: 'test', + status: 'installed', + compatibility: ['claude-code'], + tags, + exampleQueries: [], + category: 'dogfood', + }; +} + +function makeDogfoodGraph(): Graph { + const graph = new Graph(); + [ + cap('frontend-design', ['frontend', 'ui', 'redesign', 'screen']), + cap('frontend-patterns', ['frontend', 'patterns']), + cap('e2e-testing', ['e2e', 'testing']), + cap('design-review', ['design', 'review']), + cap('dashboard-builder', ['dashboard', 'metrics', 'ceo']), + cap('product-capability', ['product', 'planning', 'capability']), + cap('document-release', ['docs', 'release', 'readme', 'install']), + cap('document-review', ['docs', 'review']), + cap('devex-review', ['devex', 'docs']), + cap('ai-regression-testing', ['test', 'regression', 'failing-tests', 'fix']), + cap('github-ops', ['github', 'pull-request', 'pr']), + cap('project-session-manager', ['project', 'session']), + cap('ce:review', ['review', 'regression', 'risk']), + cap('coding-standards', ['code-quality', 'review']), + cap('agent-introspection-debugging', ['debug', 'runtime', 'stuck']), + cap('omc-doctor', ['doctor', 'runtime']), + cap('debugging', ['debug', 'crash', 'bug']), + cap('ai-slop-cleaner', ['slop', 'cleanup', 'refactor']), + cap('security-reviewer', ['security', 'auth', 'secret']), + cap('django-security', ['security']), + cap('laravel-security', ['security']), + cap('office-hours', ['product', 'strategy', 'office-hours']), + cap('plan-ceo-review', ['plan', 'ceo', 'product']), + cap('critic', ['critic', 'decision', 'risk']), + cap('ralplan', ['decision', 'tradeoff', 'planning']), + cap('architect', ['architecture', 'tradeoff', 'council']), + cap('ci-cd-best-practices', ['ci', 'release']), + cap('claude-md-improver', ['claude-md', 'docs', 'fix'], 'Audit and improve CLAUDE.md files only when requested.'), + ].forEach(node => graph.addNode(node)); + return graph; +} + +describe('route dogfood golden set', () => { + it('covers the daily routing surfaces that should not regress', () => { + expect(DOGFOOD_ROUTE_CASES.length).toBeGreaterThanOrEqual(30); + expect(DOGFOOD_ROUTE_CASES.some(testCase => testCase.query === 'bug 垮查')).toBe(true); + expect(new Set(DOGFOOD_ROUTE_CASES.map(testCase => testCase.category))).toEqual(new Set([ + 'pr', + 'review', + 'release', + 'product', + 'council', + 'debug', + 'refactor', + 'security', + 'frontend', + 'dashboard', + 'docs', + ])); + }); + + for (const testCase of DOGFOOD_ROUTE_CASES) { + it(`routes "${testCase.query}" to ${testCase.combo}`, async () => { + const spec = await buildRouteSpec(testCase.query, { + graph: makeDogfoodGraph(), + config: { ...DEFAULT_CONFIG }, + target: 'codex', + }); + + expect(spec.mode).toBe('route_plan'); + expect(spec.combo).toBe(testCase.combo); + expect(spec.choices.recommended.id).toBe(`workflow:${testCase.combo}`); + expect(spec.choices.alternatives.some(choice => choice.kind === 'model')).toBe(true); + if (testCase.choice) { + expect(spec.choices.alternatives.some(choice => choice.id === testCase.choice)).toBe(true); + } + if (testCase.combo === 'test_pr_repair') { + expect(spec.skills.some(skill => skill.name === 'claude-md-improver')).toBe(false); + } + }); + } +}); diff --git a/test/orchestrator/route-events.test.ts b/test/orchestrator/route-events.test.ts new file mode 100644 index 0000000..408c23e --- /dev/null +++ b/test/orchestrator/route-events.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { readRecentRouteEvents, readRouteStats, recordRouteAdoption, recordRouteEvent } from '../../src/orchestrator/route-events.js'; + +describe('route events', () => { + it('stores route metadata without raw query or raw warnings', () => { + const dir = mkdtempSync(join(tmpdir(), 'lazybrain-route-events-')); + const path = join(dir, 'route-events.jsonl'); + const privatePath = ['', 'Users', 'example', 'path'].join('/'); + try { + const event = recordRouteEvent({ + query: `review this private prompt with ${privatePath}`, + source: 'api', + target: 'codex', + mode: 'route_plan', + intent: 'Review', + skillIds: ['review'], + warnings: [`Embedding cache stale for ${privatePath}`], + recommendedChoice: { + id: 'mode:review', + kind: 'mode', + label: 'Review mode', + confidence: 0.8, + cost: 'medium', + latency: 'normal', + risk: 'low', + reason: 'review', + }, + path, + }); + + expect(event?.queryHash).toMatch(/^[a-f0-9]{16}$/); + const recent = readRecentRouteEvents({ path }); + expect(recent[0]).not.toHaveProperty('query'); + expect(recent[0]).not.toHaveProperty('warnings'); + expect(recent[0].warningKinds).toEqual(['embedding']); + expect(JSON.stringify(recent)).not.toContain('private prompt'); + expect(JSON.stringify(recent)).not.toContain(privatePath); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('records adoption as append-only metadata and merges it when reading stats', () => { + const dir = mkdtempSync(join(tmpdir(), 'lazybrain-route-events-')); + const path = join(dir, 'route-events.jsonl'); + try { + const event = recordRouteEvent({ + query: 'review this PR', + source: 'api', + target: 'claude', + mode: 'route_plan', + intent: 'Review', + skillIds: ['review'], + warnings: [], + path, + }); + expect(event).toBeTruthy(); + + const adopted = recordRouteAdoption({ + eventId: event!.eventId, + target: 'codex', + choiceId: 'workflow:code_review_regression', + action: 'copy_prompt', + path, + }); + + expect(adopted?.adopted).toBe(true); + expect(adopted?.adoptedTarget).toBe('codex'); + expect(readFileSync(path, 'utf-8').trim().split('\n')).toHaveLength(2); + expect(readRouteStats(path).adoptedCount).toBe(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('records rejected feedback reasons as append-only metadata', () => { + const dir = mkdtempSync(join(tmpdir(), 'lazybrain-route-events-')); + const path = join(dir, 'route-events.jsonl'); + try { + const event = recordRouteEvent({ + query: '请甚议䌚暡匏裁决这䞪架构取舍', + source: 'api', + target: 'codex', + mode: 'route_plan', + intent: 'Council escalation review', + combo: 'council_escalation', + skillIds: ['critic', 'ralplan', 'architect'], + warnings: [], + path, + }); + expect(event).toBeTruthy(); + + const rejected = recordRouteAdoption({ + eventId: event!.eventId, + choiceId: 'workflow:council_escalation', + action: 'feedback', + outcome: 'rejected', + reason: 'missed_council', + path, + }); + + expect(rejected?.feedbackOutcome).toBe('rejected'); + expect(rejected?.feedbackReason).toBe('missed_council'); + expect(readFileSync(path, 'utf-8').trim().split('\n')).toHaveLength(2); + expect(readRouteStats(path).feedbackReasons.missed_council).toBe(1); + expect(JSON.stringify(readRecentRouteEvents({ path }))).not.toContain('议䌚暡匏'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/orchestrator/route-gate.test.ts b/test/orchestrator/route-gate.test.ts index 13f2d5a..f48a0f8 100644 --- a/test/orchestrator/route-gate.test.ts +++ b/test/orchestrator/route-gate.test.ts @@ -15,6 +15,20 @@ describe('classifyRouteNeed', () => { expect(decision.category).toBe('high_risk'); }); + it('routes explicit council and tradeoff prompts', () => { + const decision = classifyRouteNeed('升级议䌚裁决这䞪架构取舍'); + expect(decision.mode).toBe('route_plan'); + expect(decision.shouldCallLazyBrain).toBe(true); + expect(decision.category).toBe('complex'); + }); + + it('treats irreversible decisions as high risk', () => { + const decision = classifyRouteNeed('irreversible architecture decision tradeoff'); + expect(decision.mode).toBe('route_plan'); + expect(decision.shouldCallLazyBrain).toBe(true); + expect(decision.category).toBe('high_risk'); + }); + it('clarifies vague voice-like prompts', () => { const decision = classifyRouteNeed('这䞪项目有点乱䜠看怎么安排'); expect(decision.mode).toBe('needs_clarification'); diff --git a/test/orchestrator/route.test.ts b/test/orchestrator/route.test.ts index d0b3a33..39078ea 100644 --- a/test/orchestrator/route.test.ts +++ b/test/orchestrator/route.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { Graph } from '../../src/graph/graph.js'; -import { buildRouteSpec } from '../../src/orchestrator/route.js'; +import { buildRouteSpec, formatRouteSpecBrief } from '../../src/orchestrator/route.js'; import { DEFAULT_CONFIG } from '../../src/constants.js'; import type { Capability } from '../../src/types.js'; @@ -63,6 +63,99 @@ function makeGraph(): Graph { exampleQueries: ['review code for regressions'], category: 'code-quality', }), + cap({ + id: 'ai-regression-testing', + name: 'ai-regression-testing', + description: 'Fix failing tests and verify regression coverage before PR handoff.', + tags: ['test', 'testing', 'regression', 'fix', 'failing-tests'], + exampleQueries: ['fix failing tests and create a PR'], + category: 'code-quality', + }), + cap({ + id: 'github-ops', + name: 'github-ops', + description: 'Prepare pull request handoffs and GitHub workflow evidence.', + tags: ['github', 'pull-request', 'pr', 'handoff'], + exampleQueries: ['create a PR with verification evidence'], + category: 'deployment', + }), + cap({ + id: 'claude-md-improver', + name: 'claude-md-improver', + description: 'Audit and improve CLAUDE.md files in repositories. Use when user asks to check, audit, update, improve, or fix CLAUDE.md files.', + tags: ['claude', 'claude-md', 'docs', 'fix', 'audit'], + exampleQueries: ['fix CLAUDE.md project memory'], + category: 'code-quality', + }), + cap({ + id: 'office-hours', + name: 'office-hours', + description: 'YC office hours for product ideas, product direction, and whether something is worth building.', + tags: ['product', 'startup', 'planning', 'office-hours'], + exampleQueries: ['help me rethink this product direction'], + category: 'planning', + }), + cap({ + id: 'product-capability', + name: 'product-capability', + description: 'Define product capabilities, wedges, and user-facing value.', + tags: ['product', 'capability', 'planning'], + exampleQueries: ['plan product direction and execution'], + category: 'planning', + }), + cap({ + id: 'architect', + name: 'architect', + description: 'Run multi-perspective council review for architecture, cost, and irreversible tradeoffs.', + tags: ['architect', 'planning', 'architecture', 'tradeoff', 'decision'], + exampleQueries: ['use architect review to decide this architecture tradeoff'], + category: 'planning', + costLevel: 'high', + riskLevel: 'caution', + }), + cap({ + id: 'critic', + name: 'critic', + description: 'Challenge assumptions and surface risks before a decision.', + tags: ['critic', 'review', 'risk', 'decision'], + exampleQueries: ['critic review this decision'], + category: 'planning', + }), + cap({ + id: 'ralplan', + name: 'ralplan', + description: 'Compare options and make a decision plan.', + tags: ['decision', 'options', 'tradeoff', 'planning'], + exampleQueries: ['choose between options with tradeoffs'], + category: 'planning', + }), + cap({ + id: 'release-risk', + name: 'release-risk', + description: 'Review production release, rollback, hook, and secret risks.', + tags: ['release', 'publish', 'production', 'rollback', 'hook', 'secret', 'token'], + exampleQueries: ['publish release to production and check secret token rollback'], + category: 'release', + riskLevel: 'destructive', + requiresConfirmation: true, + }), + cap({ + id: 'gitnexus-pr-review', + name: 'gitnexus-pr-review', + description: 'Use GitNexus knowledge graph context to review PR blast radius, risk, and missing tests.', + tags: ['gitnexus', 'knowledge-graph', 'pr', 'review', 'risk', 'impact'], + exampleQueries: ['use GitNexus to review PR risk'], + category: 'code-quality', + }), + cap({ + id: 'fresh-plugin-router', + name: 'fresh-plugin-router', + description: 'A newly installed plugin skill that should be visible before embedding coverage catches up.', + origin: 'plugin:fresh-plugin', + tags: ['fresh-plugin', 'routing', 'unlock'], + exampleQueries: ['use fresh-plugin-router for this route'], + category: 'routing', + }), ]; for (const node of nodes) graph.addNode(node); return graph; @@ -76,9 +169,16 @@ describe('buildRouteSpec', () => { }); expect(spec.mode).toBe('route_plan'); - expect(spec.schemaVersion).toBe('1.4.5'); + expect(spec.schemaVersion).toBe('1.5.0'); expect(spec.combo).toBe('dashboard_ceo'); expect(spec.whyRoute).toContain('dashboard_ceo'); + expect(spec.choices.recommended.kind).toBe('workflow'); + expect(spec.choices.recommended.id).toBe('workflow:dashboard_ceo'); + expect(spec.choices.policy.defaultAction).toBe('auto'); + expect(spec.choices.policy.askUser).toBe(false); + expect(spec.choices.alternatives.some(choice => choice.kind === 'model')).toBe(true); + expect(spec.choices.conflicts.some(conflict => conflict.group === 'skill:same-intent')).toBe(true); + expect(spec.adapters.generic.prompt).toContain('Recommended choice: dashboard_ceo'); expect(spec.tokenStrategy.includeFullSkillBody).toBe(false); expect(spec.tokenStrategy.topKSkills).toBeGreaterThan(0); expect(spec.skills.some(skill => skill.name === 'dashboard-builder')).toBe(true); @@ -94,10 +194,253 @@ describe('buildRouteSpec', () => { }); expect(spec.combo).toBe('frontend_existing_redesign'); + expect(spec.entryCommand).toContain('lazybrain route'); + expect(spec.executionMode).toBe('guided'); expect(spec.verification.some(check => check.id === 'ui-desktop-screenshot')).toBe(true); expect(spec.verification.some(check => check.id === 'ui-console-clean')).toBe(true); }); + it('routes Chinese webpage redesign phrasing to the existing redesign combo', async () => { + const spec = await buildRouteSpec('垮我重新讟计这䞪眑页', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + }); + + expect(spec.combo).toBe('frontend_existing_redesign'); + expect(spec.intent).toBe('Existing frontend redesign'); + }); + + it('routes failing tests and PR handoff away from CLAUDE.md maintenance', async () => { + const spec = await buildRouteSpec('fix failing tests and create a PR', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + target: 'codex', + }); + + expect(spec.mode).toBe('route_plan'); + expect(spec.combo).toBe('test_pr_repair'); + expect(spec.intent).toBe('Test repair and PR handoff'); + expect(spec.choices.recommended.id).toBe('workflow:test_pr_repair'); + expect(spec.skills[0]?.name).toBe('ai-regression-testing'); + expect(spec.skills.some(skill => skill.name === 'claude-md-improver')).toBe(false); + expect(spec.adapters.codex?.prompt).toContain('Test repair and PR handoff'); + }); + + it('routes Chinese failing-test PR handoff to the same combo', async () => { + const spec = await buildRouteSpec('垮我修倱莥测试并提亀 PR', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + target: 'codex', + }); + + expect(spec.mode).toBe('route_plan'); + expect(spec.combo).toBe('test_pr_repair'); + expect(spec.intent).toBe('Test repair and PR handoff'); + expect(spec.choices.recommended.id).toBe('workflow:test_pr_repair'); + expect(spec.skills[0]?.name).toBe('ai-regression-testing'); + }); + + it('does not surface generic create/plugin/router token matches as explicit skills', async () => { + const graph = makeGraph(); + graph.addNode(cap({ + id: 'skill-create', + name: 'skill-create', + description: 'Create a reusable skill.', + tags: ['skill', 'create'], + exampleQueries: ['create a skill'], + category: 'development', + })); + graph.addNode(cap({ + id: 'create-plugin', + name: 'create-plugin', + description: 'Create a plugin package.', + tags: ['plugin', 'create'], + exampleQueries: ['create a plugin'], + category: 'development', + })); + + const spec = await buildRouteSpec('fix failing tests and create a PR', { + graph, + config: { ...DEFAULT_CONFIG }, + target: 'codex', + }); + + expect(spec.combo).toBe('test_pr_repair'); + expect(spec.skills.some(skill => skill.name === 'skill-create')).toBe(false); + expect(spec.skills.some(skill => skill.name === 'create-plugin')).toBe(false); + }); + + it('formats a brief human dogfood route summary without dumping adapter prompts', async () => { + const spec = await buildRouteSpec('fix failing tests and create a PR', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + target: 'claude', + }); + + const output = formatRouteSpecBrief(spec); + + expect(output).toContain('Route: test_pr_repair'); + expect(output).toContain('Recommended: workflow:test_pr_repair'); + expect(output).toContain('Use: ai-regression-testing, github-ops'); + expect(output).toContain('Missing: project-session-manager (generic prompt)'); + expect(output).toContain('Prompt: lazybrain prompt "fix failing tests and create a PR" --target claude --copy'); + expect(output).not.toContain('adapter prompt'); + expect(output.split('\n').length).toBeLessThanOrEqual(3); + }); + + it('does not treat prepare/test-plan wording as a PR handoff', async () => { + const spec = await buildRouteSpec('prepare a test plan for this module', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + }); + + expect(spec.combo).not.toBe('test_pr_repair'); + }); + + it('routes Chinese product replanning to product direction planning', async () => { + const spec = await buildRouteSpec('垮我重新规划产品方向和执行方案', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + }); + + expect(spec.mode).toBe('route_plan'); + expect(spec.combo).toBe('product_direction_planning'); + expect(spec.intent).toBe('Product direction planning'); + expect(spec.skills.some(skill => skill.name === 'office-hours')).toBe(true); + }); + + it('routes council-mode architecture tradeoffs to council escalation', async () => { + const spec = await buildRouteSpec('请甚 council/议䌚暡匏裁决这䞪架构取舍确讀项目是吊笊合预期标准', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + target: 'claude', + }); + + expect(spec.mode).toBe('route_plan'); + expect(spec.combo).toBe('council_escalation'); + expect(spec.intent).toBe('Council escalation review'); + expect(spec.choices.recommended.id).toBe('workflow:council_escalation'); + expect(spec.choices.alternatives.some(choice => choice.id === 'mode:council' && choice.confidence >= 0.8)).toBe(true); + expect(spec.choices.alternatives.some(choice => choice.kind === 'model' && choice.cost === 'high')).toBe(true); + expect(spec.tokenStrategy.suggestSubagents).toBe(true); + expect(spec.skills.some(skill => skill.name === 'critic')).toBe(true); + expect(spec.skills.some(skill => skill.name === 'ralplan')).toBe(true); + expect(spec.skills.some(skill => skill.name === 'architect')).toBe(true); + expect(spec.skills.every(skill => skill.available)).toBe(true); + expect(spec.guardrails.some(rule => rule.title.includes('irreversible'))).toBe(true); + }); + + it('keeps tag fallback useful when hybrid semantic cache is unavailable', async () => { + const spec = await buildRouteSpec('review this PR for regressions', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG, engine: 'hybrid' }, + }); + + expect(spec.combo).toBe('code_review_regression'); + expect(spec.choices.recommended.id).toBe('workflow:code_review_regression'); + expect(spec.warnings.some(warning => warning.toLowerCase().includes('semantic') || warning.toLowerCase().includes('embedding'))).toBe(true); + }); + + it('keeps provider-specific code graph skills hidden unless explicitly requested', async () => { + const spec = await buildRouteSpec('review this PR for regressions', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + target: 'codex', + }); + + const output = formatRouteSpecBrief(spec); + + expect(spec.skills.some(skill => skill.name === 'gitnexus-pr-review')).toBe(false); + expect(output).not.toContain('gitnexus-pr-review'); + }); + + it('surfaces explicitly named installed skills alongside combo skills in brief output', async () => { + const spec = await buildRouteSpec('我刚装了䞀䞪 GitNexus 插件, 垮我 review PR 风险', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + target: 'claude', + }); + + const output = formatRouteSpecBrief(spec); + + expect(spec.combo).toBe('code_review_regression'); + expect(spec.skills.some(skill => skill.name === 'gitnexus-pr-review')).toBe(true); + expect(output).toContain('Use: ce:review, ai-regression-testing, gitnexus-pr-review'); + expect(output).toContain('Missing: coding-standards'); + }); + + it('surfaces explicitly named newly installed plugin skills before semantic coverage', async () => { + const spec = await buildRouteSpec('我刚装了 fresh-plugin-router 插件垮我 route 这䞪 workflow', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + target: 'claude', + }); + + const explicit = spec.skills.find(skill => skill.name === 'fresh-plugin-router'); + expect(explicit).toBeDefined(); + expect(explicit?.layer).toBe('alias'); + expect(explicit?.score).toBeGreaterThanOrEqual(0.9); + expect(formatRouteSpecBrief(spec)).toContain('fresh-plugin-router'); + }); + + it('does not route function refactors to the frontend redesign combo', async () => { + const spec = await buildRouteSpec('垮我重构这䞪凜数', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + }); + + expect(spec.combo).not.toBe('frontend_existing_redesign'); + }); + + it('does not route API publishing to the public npm release combo', async () => { + const spec = await buildRouteSpec('准倇发垃这䞪API', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + }); + + expect(spec.combo).not.toBe('release_public_audit'); + }); + + it('routes crash and bug phrasing to the debug crash combo', async () => { + const spec = await buildRouteSpec('这䞪 bug 厩溃了垮我排查报错', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + }); + + expect(spec.combo).toBe('debug_crash'); + expect(spec.executionPlan.some(step => step.id === 'reproduce-failure')).toBe(true); + }); + + it('routes short mixed-punctuation bug investigation phrasing to the debug crash combo', async () => { + const spec = await buildRouteSpec('bug 垮查', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + }); + + expect(spec.combo).toBe('debug_crash'); + expect(spec.choices.recommended.id).toBe('workflow:debug_crash'); + }); + + it('routes messy code cleanup to the refactor combo', async () => { + const spec = await buildRouteSpec('枅理这段臃肿的垃土代码', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + }); + + expect(spec.combo).toBe('refactor_clean'); + expect(spec.guardrails.some(rule => rule.title.includes('Preserve external behavior'))).toBe(true); + }); + + it('routes auth and permission risk to the security audit combo', async () => { + const spec = await buildRouteSpec('检查讀证权限和密钥泄挏安党风险', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + }); + + expect(spec.combo).toBe('audit_security'); + expect(spec.executionPlan.some(step => step.id === 'map-trust-boundary')).toBe(true); + }); + it('returns docs workflow without execution controls', async () => { const spec = await buildRouteSpec('把安装流皋写给普通甚户曎新 README', { graph: makeGraph(), @@ -121,6 +464,37 @@ describe('buildRouteSpec', () => { expect(spec.verification.some(check => check.id === 'hook-rollback')).toBe(true); }); + it('deduplicates repeated display skills and verification commands in route output', async () => { + const graph = makeGraph(); + graph.addNode(cap({ + id: 'review-pr-a', + name: 'review-pr', + description: 'Review pull requests for regressions.', + tags: ['review', 'pr', 'regression'], + category: 'code-quality', + })); + graph.addNode(cap({ + id: 'review-pr-b', + name: 'review-pr', + description: 'Alternative review PR provider.', + tags: ['review', 'pr', 'regression'], + category: 'code-quality', + })); + + const reviewSpec = await buildRouteSpec('review this PR for regressions', { + graph, + config: { ...DEFAULT_CONFIG }, + }); + expect(reviewSpec.skills.filter(skill => skill.name === 'review-pr')).toHaveLength(1); + + const releaseSpec = await buildRouteSpec('检查公匀安装 hook 的隐私和回滚风险然后准倇 release', { + graph, + config: { ...DEFAULT_CONFIG }, + }); + const commands = releaseSpec.verification.map(check => check.command).filter(Boolean); + expect(commands).toHaveLength(new Set(commands).size); + }); + it('returns needs_clarification for vague voice-like query', async () => { const spec = await buildRouteSpec('这䞪项目有点乱䜠看怎么安排', { graph: makeGraph(), @@ -128,6 +502,10 @@ describe('buildRouteSpec', () => { }); expect(spec.mode).toBe('needs_clarification'); + expect(spec.choices.recommended.id).toBe('mode:clarify-first'); + expect(spec.choices.policy.defaultAction).toBe('ask'); + expect(spec.choices.policy.askUser).toBe(true); + expect(spec.choices.policy.reason).toContain('clarify'); expect(spec.tokenStrategy.shouldClarifyFirst).toBe(true); expect(spec.clarificationQuestions?.length).toBeGreaterThan(0); expect(spec.skills).toEqual([]); @@ -140,6 +518,8 @@ describe('buildRouteSpec', () => { }); expect(spec.mode).toBe('no_route_needed'); + expect(spec.choices.recommended.id).toBe('mode:direct'); + expect(spec.choices.policy.askUser).toBe(false); expect(spec.skills).toEqual([]); expect(spec.tokenStrategy.topKSkills).toBe(0); expect(spec.tokenStrategy.includeFullSkillBody).toBe(false); @@ -153,7 +533,82 @@ describe('buildRouteSpec', () => { }); expect(spec.target).toBe('codex'); + expect(spec.entryCommand).toContain('--target codex'); expect(spec.adapters.generic.prompt).toContain('Generic AI agent'); expect(spec.adapters.codex?.prompt).toContain('Codex advisory route plan'); }); + + it('renders combo entry commands for the requested target', async () => { + const spec = await buildRouteSpec('review code for regressions', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + target: 'cursor', + }); + + expect(spec.entryCommand).toBe('lazybrain route "" --target cursor'); + expect(spec.entryCommand).not.toContain('codex'); + }); + + it('ranks strong models and verification modes for high-risk release work', async () => { + const spec = await buildRouteSpec('publish release to production and check secret token rollback', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + }); + + expect(spec.mode).toBe('route_plan'); + expect(spec.choices.policy.defaultAction).toBe('ask'); + expect(spec.choices.policy.askUser).toBe(true); + expect(spec.choices.policy.reason).toContain('requires confirmation'); + expect(spec.choices.alternatives.some(choice => choice.id === 'model:strong-reasoning')).toBe(true); + expect(spec.choices.alternatives.some(choice => choice.id === 'model:local-private')).toBe(true); + expect(spec.choices.alternatives.some(choice => choice.id === 'mode:review' || choice.id === 'mode:qa')).toBe(true); + }); + + it('surfaces autopilot mode as an explicit high-risk alternative', async () => { + const spec = await buildRouteSpec('autopilot 端到端完成这䞪 review', { + graph: makeGraph(), + config: { ...DEFAULT_CONFIG }, + }); + + const autopilot = spec.choices.alternatives.find(choice => choice.id === 'mode:autopilot'); + expect(autopilot?.confidence).toBeGreaterThanOrEqual(0.7); + expect(autopilot?.risk).toBe('high'); + }); + + it('reports registry conflict groups in route choices', async () => { + const graph = new Graph(); + graph.addNode(cap({ + id: 'core-review', + name: 'core-review', + description: 'Review code for regressions.', + tags: ['review', 'regression'], + exampleQueries: ['review code'], + category: 'code-quality', + origin: 'core', + provider: 'core', + conflictGroup: 'skill:review', + sourcePriority: 0, + })); + graph.addNode(cap({ + id: 'plugin-review', + name: 'plugin-review', + description: 'Review code for regressions.', + tags: ['review', 'regression'], + exampleQueries: ['review code'], + category: 'code-quality', + origin: 'plugin', + provider: 'plugin', + conflictGroup: 'skill:review', + sourcePriority: 10, + })); + + const spec = await buildRouteSpec('review code for regressions', { + graph, + config: { ...DEFAULT_CONFIG }, + }); + + expect(spec.choices.conflicts.some(conflict => conflict.group === 'skill:review')).toBe(true); + expect(spec.choices.conflicts.find(conflict => conflict.group === 'skill:review')?.suggestedAction) + .toContain('Use the winner'); + }); }); diff --git a/test/privacy/prompts.test.ts b/test/privacy/prompts.test.ts new file mode 100644 index 0000000..a5bc43f --- /dev/null +++ b/test/privacy/prompts.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { redactPromptForStorage, sanitizePromptRecord } from '../../src/privacy/prompts.js'; + +describe('prompt privacy helpers', () => { + it('redacts raw prompts into stable hash labels', () => { + const privatePath = ['', 'Users', 'me', 'project'].join('/'); + const first = redactPromptForStorage(`review private repo path ${privatePath}`); + const second = redactPromptForStorage(`review private repo path ${privatePath}`); + + expect(first.query).toMatch(/^\[redacted-prompt:[a-f0-9]{16}\]$/); + expect(first.queryHash).toBe(second.queryHash); + expect(JSON.stringify(first)).not.toContain(privatePath); + }); + + it('sanitizes legacy query records on read', () => { + const record = sanitizePromptRecord({ query: 'raw private prompt', matched: 'tool-a' }); + + expect(record.query).toMatch(/^\[redacted-prompt:[a-f0-9]{16}\]$/); + expect(record).toHaveProperty('queryHash'); + expect(JSON.stringify(record)).not.toContain('raw private prompt'); + }); +}); diff --git a/test/runtime/status.test.ts b/test/runtime/status.test.ts new file mode 100644 index 0000000..30a2308 --- /dev/null +++ b/test/runtime/status.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { mergeRuntimeStatus } from '../../src/runtime/status.js'; + +describe('runtime status merge', () => { + it('preserves previous unlock timestamps when writing transient state', () => { + const dir = mkdtempSync(join(tmpdir(), 'lazybrain-runtime-status-')); + const path = join(dir, 'status.json'); + try { + writeFileSync(path, JSON.stringify({ + state: 'idle', + lastScanAt: 111, + lastCompileAt: 222, + newCapabilities: ['fresh-plugin-router'], + }), 'utf-8'); + + mergeRuntimeStatus({ state: 'embedding', progress: 'incremental' }, path); + mergeRuntimeStatus({ state: 'idle', lastEmbeddingAt: 333 }, path); + + const status = JSON.parse(readFileSync(path, 'utf-8')) as Record; + expect(status.lastScanAt).toBe(111); + expect(status.lastCompileAt).toBe(222); + expect(status.lastEmbeddingAt).toBe(333); + expect(status.newCapabilities).toEqual(['fresh-plugin-router']); + expect(status.updatedAt).toEqual(expect.any(Number)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/scanner/scanner.test.ts b/test/scanner/scanner.test.ts index b04d778..841ca38 100644 --- a/test/scanner/scanner.test.ts +++ b/test/scanner/scanner.test.ts @@ -17,6 +17,16 @@ describe('scanner', () => { expect(fixtureSkills.length).toBeGreaterThanOrEqual(2); }); + it('scans top-level skillshub-style skill roots', () => { + const result = scan({ + extraPaths: [resolve(fixturesDir, '.skillshub')], + }); + + const skill = result.capabilities.find(c => c.name === 'test-ecc-skill'); + expect(skill?.kind).toBe('skill'); + expect(skill?.origin).toBe('ECC'); + }); + it('scans agents from extra paths', () => { const result = scan({ extraPaths: [resolve(fixturesDir, 'agents')], diff --git a/test/scanner/skill-parser.test.ts b/test/scanner/skill-parser.test.ts index 8776865..c3516cb 100644 --- a/test/scanner/skill-parser.test.ts +++ b/test/scanner/skill-parser.test.ts @@ -47,6 +47,28 @@ trigger: "when writing new functions" expect(result!.triggers).toEqual(['when writing new functions']); }); + it('parses conflict governance metadata', () => { + const content = `--- +name: hook-manager +description: Install and repair hook configuration +provider: lazybrain-core +conflictGroup: hook:user-prompt-submit +sideEffects: changes_config, installs_hooks +--- + +# Hook Manager`; + + const result = parseSkill( + '/home/user/.claude/skills/hook-manager/SKILL.md', + content + ); + + expect(result).not.toBeNull(); + expect(result!.provider).toBe('lazybrain-core'); + expect(result!.conflictGroup).toBe('hook:user-prompt-submit'); + expect(result!.sideEffects).toEqual(['changes_config', 'installs_hooks']); + }); + it('infers origin from path when not in frontmatter', () => { const content = `--- name: graphify diff --git a/test/server/liveness.test.ts b/test/server/liveness.test.ts new file mode 100644 index 0000000..94dbbf3 --- /dev/null +++ b/test/server/liveness.test.ts @@ -0,0 +1,48 @@ +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { DEFAULT_PORT, getServerPort, getServerRuntimeState } from '../../src/server/liveness.js'; + +function paths(dir: string) { + return { + runningFlagPath: join(dir, '.server-running'), + pidFilePath: join(dir, 'server.pid'), + }; +} + +describe('server liveness markers', () => { + it('treats stale marker files as not running and cleans them', () => { + const dir = mkdtempSync(join(tmpdir(), 'lazybrain-liveness-stale-')); + const p = paths(dir); + writeFileSync(p.runningFlagPath, '18450', 'utf-8'); + writeFileSync(p.pidFilePath, '99999999', 'utf-8'); + + const state = getServerRuntimeState(p); + + expect(state.running).toBe(false); + expect(state.pid).toBeNull(); + expect(state.port).toBe(18450); + expect(existsSync(p.runningFlagPath)).toBe(false); + expect(existsSync(p.pidFilePath)).toBe(false); + }); + + it('requires both a marker file and a live pid', () => { + const dir = mkdtempSync(join(tmpdir(), 'lazybrain-liveness-live-')); + const p = paths(dir); + writeFileSync(p.runningFlagPath, '4567', 'utf-8'); + writeFileSync(p.pidFilePath, String(process.pid), 'utf-8'); + + const state = getServerRuntimeState(p); + + expect(state).toEqual({ running: true, port: 4567, pid: process.pid }); + }); + + it('falls back to the default port for invalid marker content', () => { + const dir = mkdtempSync(join(tmpdir(), 'lazybrain-liveness-port-')); + const p = paths(dir); + writeFileSync(p.runningFlagPath, 'not-a-port', 'utf-8'); + + expect(getServerPort(p)).toBe(DEFAULT_PORT); + }); +}); diff --git a/test/server/server.test.ts b/test/server/server.test.ts index 198e84a..bf514a5 100644 --- a/test/server/server.test.ts +++ b/test/server/server.test.ts @@ -4,11 +4,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import * as http from 'node:http'; -import { homedir } from 'node:os'; -import { createRouter } from '../../src/server/router.js'; +import { homedir, tmpdir } from 'node:os'; +import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { buildCompileArgs, createRouter, HTTP_ROUTES } from '../../src/server/router.js'; +import { sanitizeConfigUpdate } from '../../src/config/schema.js'; +import { getJob } from '../../src/runtime/jobs.js'; import { Graph } from '../../src/graph/graph.js'; import type { UserConfig } from '../../src/types.js'; -import { DEFAULT_CONFIG } from '../../src/constants.js'; +import { DEFAULT_CONFIG, STATUS_PATH } from '../../src/constants.js'; +import { UI_HTML } from '../../src/ui/html.js'; // ─── Mock Graph ─────────────────────────────────────────────────────────────── @@ -46,8 +51,10 @@ function makeMockGraph(): Graph { let server: http.Server; let baseUrl: string; let graph: Graph; +let tempDir: string; beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'lazybrain-server-')); graph = makeMockGraph(); const config: UserConfig = { ...DEFAULT_CONFIG }; @@ -56,6 +63,7 @@ beforeAll(async () => { config, version: '0.1.0-test', onReload: () => { graph = makeMockGraph(); }, + routeEventsPath: join(tempDir, 'route-events.jsonl'), }); server = http.createServer(router); @@ -68,6 +76,7 @@ afterAll(async () => { await new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve())), ); + rmSync(tempDir, { recursive: true, force: true }); }); // ─── Helper ─────────────────────────────────────────────────────────────────── @@ -82,6 +91,14 @@ async function req(method: string, path: string, body?: unknown) { return { status: res.status, body: json }; } +async function waitForJobTerminal(jobId: string): Promise { + for (let i = 0; i < 30; i++) { + const state = getJob(jobId)?.state; + if (state && state !== 'queued' && state !== 'running') return; + await new Promise(resolve => setTimeout(resolve, 25)); + } +} + // ─── Tests ──────────────────────────────────────────────────────────────────── describe('GET /health', () => { @@ -100,15 +117,29 @@ describe('GET /health', () => { }); }); +describe('compile job options', () => { + it('keeps relation inference out of default compile args', () => { + expect(buildCompileArgs({})).toEqual(['compile']); + expect(buildCompileArgs({ withRelations: true })).toEqual(['compile', '--with-relations']); + expect(buildCompileArgs({ withRelations: true, forceRelations: true })).toEqual(['compile', '--with-relations', '--force-relations']); + }); +}); + describe('GUI routes', () => { - it('serves the Overview UI at / and /ui', async () => { + it('serves the Workbench UI at / and /ui', async () => { for (const path of ['/', '/ui']) { const res = await fetch(`${baseUrl}${path}`); const text = await res.text(); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('text/html'); expect(text).toContain('LazyBrain'); - expect(text).toContain('Try Router'); + expect(text).toContain('LazyBrain Workbench'); + expect(text).toContain('/api/route'); + expect(text).toContain('/api/compile/status'); + expect(text).not.toContain('cytoscape'); + expect(text).not.toContain('/api/choices'); + expect(text).not.toContain('/api/jobs'); + expect(text).not.toContain('/api/repairs'); } }); @@ -116,14 +147,78 @@ describe('GUI routes', () => { const { status, body } = await req('GET', '/api/status'); expect(status).toBe(200); expect(body).toHaveProperty('version'); + expect(body).toHaveProperty('product'); + expect(body.product).toHaveProperty('state'); expect(body).toHaveProperty('readiness'); expect(body).toHaveProperty('graph'); + expect(body).toHaveProperty('gitNexus'); + expect(body.gitNexus).toHaveProperty('available'); + expect(body.gitNexus).toHaveProperty('mcpRequired', false); + expect(body.gitNexus).toHaveProperty('state'); + expect(body.gitNexus).toHaveProperty('artifactWarnings'); expect(body).toHaveProperty('routing'); expect(body).toHaveProperty('embedding'); + expect(body).toHaveProperty('unlock'); + expect(body).toHaveProperty('modelHealth'); + expect(body).toHaveProperty('runtimeStatus'); expect(body).toHaveProperty('hook'); expect(body).toHaveProperty('agents'); expect(body).toHaveProperty('server'); expect(body.config).not.toHaveProperty('compileApiKey'); + expect(JSON.stringify(body.modelHealth)).not.toContain('ApiKey'); + expect(JSON.stringify(body.runtimeStatus)).not.toContain('ApiKey'); + }); + + it('exposes the active route registry', async () => { + const { status, body } = await req('GET', '/api/routes'); + expect(status).toBe(200); + expect(body.routes).toEqual(HTTP_ROUTES.map(route => ({ ...route }))); + expect(body.routes).toContainEqual(expect.objectContaining({ + method: 'POST', + path: '/api/route', + handler: 'handleRoute', + surface: 'api', + })); + expect(body.routes).toContainEqual(expect.objectContaining({ + method: 'GET', + path: '/api/status', + handler: 'handleStatus', + surface: 'api', + })); + }); + + it('reports persisted graph compile errors through /api/status', async () => { + graph.setCompileInfo('test-model', ['relation_invalid_type:source->target: blocks']); + try { + const { status, body } = await req('GET', '/api/status'); + expect(status).toBe(200); + expect(body.ok).toBe(false); + expect(body.product.state).toBe('NOT_READY'); + expect(body.product.blockers.join('\n')).toContain('Graph has 1 compile errors'); + expect(body.readiness.blockers.join('\n')).toContain('Graph has 1 compile errors'); + } finally { + graph.setCompileInfo('test-model', []); + } + }); + + it('marks stale persisted compile status without keeping readiness blocked', async () => { + const hadStatus = existsSync(STATUS_PATH); + const previousStatus = hadStatus ? readFileSync(STATUS_PATH, 'utf-8') : null; + mkdirSync(dirname(STATUS_PATH), { recursive: true }); + writeFileSync(STATUS_PATH, JSON.stringify({ state: 'compiling', progress: '292/859', updatedAt: Date.now() }), 'utf-8'); + try { + const { status, body } = await req('GET', '/api/status'); + expect(status).toBe(200); + expect(body.runtimeStatus.stale).toBe(true); + expect(body.runtimeStatus.staleReason).toContain('no active compile process'); + expect(body.readiness.blockers.join('\n')).not.toContain('Compile state is still compiling'); + } finally { + if (previousStatus !== null) { + writeFileSync(STATUS_PATH, previousStatus, 'utf-8'); + } else { + unlinkSync(STATUS_PATH); + } + } }); it('reports embedding status through the GUI API', async () => { @@ -139,12 +234,51 @@ describe('GUI routes', () => { expect(body.error).toContain('confirm'); }); + it('accepts both boolean and string embedding rebuild confirmations and returns jobs', async () => { + const hadStatus = existsSync(STATUS_PATH); + const previousStatus = hadStatus ? readFileSync(STATUS_PATH, 'utf-8') : null; + try { + const first = await req('POST', '/api/embeddings/rebuild', { confirm: true }); + expect(first.status).toBe(200); + expect(first.body).toHaveProperty('jobId'); + + expect(getJob(first.body.jobId)?.kind).toBe('embedding'); + await waitForJobTerminal(first.body.jobId); + + const second = await req('POST', '/api/embeddings/rebuild', { confirm: 'rebuild' }); + expect(second.status).toBe(200); + expect(second.body).toHaveProperty('jobId'); + await waitForJobTerminal(second.body.jobId); + } finally { + if (previousStatus !== null) { + writeFileSync(STATUS_PATH, previousStatus, 'utf-8'); + } else if (!hadStatus) { + unlinkSync(STATUS_PATH); + } + } + }); + + it('serves redacted config only', async () => { + const configRes = await req('GET', '/api/config'); + expect(configRes.status).toBe(200); + expect(JSON.stringify(configRes.body)).not.toContain('sk-'); + }); + it('runs API tests only when explicitly requested', async () => { const { status, body } = await req('POST', '/api/test', { targets: ['compile'] }); expect(status).toBe(200); expect(body).toHaveProperty('results'); expect(body.results[0].target).toBe('compile'); }); + + it('keeps blank secret config fields as no-ops', () => { + const result = sanitizeConfigUpdate({ + compileApiKey: '', + compileApiBase: 'https://api.example.test/v1', + }); + expect(result.patch).toEqual({ compileApiBase: 'https://api.example.test/v1' }); + expect(result.ignoredKeys).toEqual(['compileApiKey']); + }); }); describe('Lab routes', () => { @@ -214,10 +348,13 @@ describe('POST /api/route', () => { const { status, body } = await req('POST', '/api/route', { query: 'review code for regressions', target: 'codex' }); expect(status).toBe(200); expect(body).toHaveProperty('query'); - expect(body).toHaveProperty('schemaVersion', '1.4.5'); + expect(body).toHaveProperty('schemaVersion', '1.5.0'); expect(body).toHaveProperty('mode'); expect(body).toHaveProperty('intent'); expect(body).toHaveProperty('whyRoute'); + expect(body).toHaveProperty('choices'); + expect(body.choices).toHaveProperty('recommended'); + expect(body.choices.alternatives.some((choice: { kind: string }) => choice.kind === 'model')).toBe(true); expect(body).toHaveProperty('skills'); expect(body).toHaveProperty('tokenStrategy'); expect(body.tokenStrategy.includeFullSkillBody).toBe(false); @@ -227,6 +364,8 @@ describe('POST /api/route', () => { expect(body).toHaveProperty('verification'); expect(body).toHaveProperty('doneWhen'); expect(body).toHaveProperty('adapters'); + expect(body).not.toHaveProperty('routeEventId'); + expect(body).toHaveProperty('unlockWarnings'); expect(body.adapters.codex.prompt).toContain('Codex advisory route plan'); expect(JSON.stringify(body)).not.toContain(homedir()); }); @@ -242,6 +381,66 @@ describe('POST /api/route', () => { expect(status).toBe(413); expect(body.error).toContain('too long'); }); + it('does not persist public route telemetry', async () => { + const routeEventsPath = join(tempDir, 'route-events.jsonl'); + if (existsSync(routeEventsPath)) unlinkSync(routeEventsPath); + + const route = await req('POST', '/api/route', { query: 'review code for regressions', target: 'claude' }); + expect(route.status).toBe(200); + expect(route.body).not.toHaveProperty('routeEventId'); + expect(existsSync(routeEventsPath)).toBe(false); + }); + + it('does not expose the legacy route-events API', async () => { + const events = await req('GET', '/api/route-events?limit=5'); + expect(events.status).toBe(404); + }); +}); + +describe('UI HTML', () => { + it('ships executable inline scripts', () => { + const scripts = [...UI_HTML.matchAll(/]*)?>([\s\S]*?)<\/script>/gi)] + .map(match => match[1]) + .filter(script => script.trim().length > 0); + expect(scripts.length).toBeGreaterThan(0); + + const mainScript = scripts.sort((a, b) => b.length - a.length)[0]; + expect(mainScript).toContain('async function load'); + expect(() => new Function(mainScript)).not.toThrow(); + }); + + it('renders the compact workbench without unfinished provider branding', () => { + expect(UI_HTML).toContain('LazyBrain Workbench'); + expect(UI_HTML).toContain('status.product'); + expect(UI_HTML).toContain('/api/status'); + expect(UI_HTML).toContain('/api/diagnostics'); + expect(UI_HTML).not.toContain('/api/route-events'); + expect(UI_HTML).not.toContain('routeEventId'); + expect(UI_HTML).not.toContain('GitNexus 玢匕'); + }); +}); + +describe('GET /api/diagnostics privacy', () => { + it('returns sanitized route events from the configured event store', async () => { + const routeEventsPath = join(tempDir, 'route-events.jsonl'); + if (existsSync(routeEventsPath)) unlinkSync(routeEventsPath); + appendFileSync(routeEventsPath, JSON.stringify({ + timestamp: new Date().toISOString(), + source: 'api', + queryHash: 'legacybadqueryhash', + query: 'legacy raw query should not leak', + mode: 'route_plan', + skillIds: [], + warningKinds: [], + }) + '\n', 'utf-8'); + + const diagnostics = await req('GET', '/api/diagnostics'); + expect(diagnostics.status).toBe(200); + expect(diagnostics.body).toHaveProperty('gitNexus'); + expect(diagnostics.body.gitNexus).toHaveProperty('mcpRequired', false); + expect(diagnostics.body.recentEvents[0]).toHaveProperty('queryHash'); + expect(JSON.stringify(diagnostics.body)).not.toContain('legacy raw query should not leak'); + }); }); describe('API aliases', () => { diff --git a/test/statusline.test.ts b/test/statusline.test.ts new file mode 100644 index 0000000..d2cbb09 --- /dev/null +++ b/test/statusline.test.ts @@ -0,0 +1,169 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('statusline route activity', () => { + it('hides fresh route internals instead of turning routing into HUD state', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'lazybrain-statusline-')); + const routeEventsPath = join(tempDir, 'route-events.jsonl'); + + try { + writeFileSync(routeEventsPath, JSON.stringify({ + eventId: 'event-1', + timestamp: new Date().toISOString(), + source: 'cli', + queryHash: 'hash', + mode: 'route_plan', + intent: 'Test repair and PR handoff', + combo: 'test_pr_repair', + recommendedChoice: { + id: 'workflow:test_pr_repair', + kind: 'workflow', + label: 'test_pr_repair', + confidence: 0.86, + }, + skillIds: [], + warningKinds: [], + semanticWarning: false, + }) + '\n', 'utf-8'); + + const output = execFileSync(process.execPath, [resolve(process.cwd(), 'dist/bin/statusline.js')], { + cwd: process.cwd(), + encoding: 'utf-8', + env: { + ...process.env, + HOME: tempDir, + LAZYBRAIN_ROUTE_EVENTS_PATH: routeEventsPath, + }, + }); + + expect(output).toContain('埅机䞭'); + expect(output).not.toContain('路由 Test repair and PR handoff [86%]'); + expect(output).not.toContain('䞊次'); + expect(output).not.toContain('cli '); + expect(output).not.toContain('test_pr_repair'); + expect(output).not.toContain('囟谱'); + expect(output).not.toContain('GNX'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('hides stale route internals instead of showing old routing state', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'lazybrain-statusline-')); + const routeEventsPath = join(tempDir, 'route-events.jsonl'); + + try { + writeFileSync(routeEventsPath, JSON.stringify({ + eventId: 'event-old', + timestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + source: 'cli', + queryHash: 'hash', + mode: 'route_plan', + combo: 'route_dogfood', + recommendedChoice: { + id: 'workflow:route_dogfood', + kind: 'workflow', + label: 'route_dogfood', + confidence: 0.86, + }, + skillIds: [], + warningKinds: [], + semanticWarning: false, + }) + '\n', 'utf-8'); + + const output = execFileSync(process.execPath, [resolve(process.cwd(), 'dist/bin/statusline.js')], { + cwd: process.cwd(), + encoding: 'utf-8', + env: { + ...process.env, + HOME: tempDir, + LAZYBRAIN_ROUTE_EVENTS_PATH: routeEventsPath, + }, + }); + + expect(output).toContain('埅机䞭'); + expect(output).not.toContain('䞊次'); + expect(output).not.toContain('route_dogfood'); + expect(output).not.toContain('囟谱'); + expect(output).not.toContain('GNX'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('does not fall back to global upstream HUD when explicit project chain is missing', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'lazybrain-statusline-chain-')); + const missingProjectChain = join(tempDir, 'project', 'missing-chain.json'); + const globalChain = join(tempDir, '.claude', 'lazybrain-statusline-chain.json'); + + try { + mkdirSync(join(tempDir, '.claude'), { recursive: true }); + writeFileSync(globalChain, JSON.stringify({ upstreamCommand: 'printf GLOBAL_HUD' }), 'utf-8'); + + const output = execFileSync(process.execPath, [resolve(process.cwd(), 'dist/bin/statusline-combined.js')], { + cwd: process.cwd(), + encoding: 'utf-8', + env: { + ...process.env, + HOME: tempDir, + LAZYBRAIN_STATUSLINE_CHAIN: missingProjectChain, + }, + }); + + expect(output).not.toContain('GLOBAL_HUD'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('preserves upstream HUD when Claude still calls standalone statusline', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'lazybrain-statusline-upstream-')); + const chainPath = join(tempDir, '.claude', 'lazybrain-statusline-chain.json'); + + try { + mkdirSync(join(tempDir, '.claude'), { recursive: true }); + writeFileSync(chainPath, JSON.stringify({ upstreamCommand: 'printf UPSTREAM_HUD' }), 'utf-8'); + + const output = execFileSync(process.execPath, [resolve(process.cwd(), 'dist/bin/statusline.js')], { + cwd: process.cwd(), + encoding: 'utf-8', + env: { + ...process.env, + HOME: tempDir, + }, + }); + + expect(output.trim()).toBe('UPSTREAM_HUD'); + expect(output).not.toContain('埅机䞭'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('keeps combined HUD from duplicating upstream via standalone fallback', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'lazybrain-statusline-combined-')); + const chainPath = join(tempDir, '.claude', 'lazybrain-statusline-chain.json'); + + try { + mkdirSync(join(tempDir, '.claude'), { recursive: true }); + writeFileSync(chainPath, JSON.stringify({ upstreamCommand: 'printf UPSTREAM_HUD' }), 'utf-8'); + + const output = execFileSync(process.execPath, [resolve(process.cwd(), 'dist/bin/statusline-combined.js')], { + cwd: process.cwd(), + encoding: 'utf-8', + env: { + ...process.env, + HOME: tempDir, + LAZYBRAIN_STATUSLINE_CHAIN: chainPath, + }, + }); + + expect(output.trim()).toBe('UPSTREAM_HUD'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/utils/hud-normalizer.test.ts b/test/utils/hud-normalizer.test.ts index e2a6ae8..c30d091 100644 --- a/test/utils/hud-normalizer.test.ts +++ b/test/utils/hud-normalizer.test.ts @@ -18,13 +18,15 @@ describe('simplifyUpstreamHud', () => { }); describe('isLowSignalLazyBrainLabel', () => { - it('always returns false so dormant labels remain visible in combined HUD', () => { - expect(isLowSignalLazyBrainLabel('🧠 0秒前 已跳过')).toBe(false); - expect(isLowSignalLazyBrainLabel('🧠 埅机䞭')).toBe(false); + it('hides dormant LazyBrain labels in combined HUD', () => { + expect(isLowSignalLazyBrainLabel('🧠 0秒前 已跳过')).toBe(true); + expect(isLowSignalLazyBrainLabel('\u001b[2m🧠 埅机䞭\u001b[0m')).toBe(true); + expect(isLowSignalLazyBrainLabel('🧠 䞊次 36分前 api code_review_regression [86%]')).toBe(true); }); it('keeps active labels visible', () => { expect(isLowSignalLazyBrainLabel('🧠 /review-pr [43%]')).toBe(false); expect(isLowSignalLazyBrainLabel('🧠 思考䞭')).toBe(false); + expect(isLowSignalLazyBrainLabel('🧠 3秒前 api 路由 Regression code review [86%]')).toBe(false); }); });