From bfe5426b7e9029b219882a636cdee800fd17041d Mon Sep 17 00:00:00 2001 From: catlog22 Date: Tue, 17 Mar 2026 12:55:14 +0800 Subject: [PATCH] Refactor agent spawning and delegation check mechanisms - Updated agent spawning from `Task()` to `Agent()` across various files to align with new standards. - Enhanced the `code-developer` agent description to clarify its invocation context and responsibilities. - Introduced a new `delegation-check` skill to validate command delegation prompts against agent role definitions, ensuring content separation and conflict detection. - Established comprehensive separation rules for command delegation prompts and agent definitions, detailing ownership and conflict patterns. - Improved documentation for command and agent design specifications to reflect the updated spawning patterns and validation processes. --- .claude/agents/cli-execution-agent.md | 121 ++++-- .claude/agents/cli-planning-agent.md | 222 +++++++--- .claude/agents/code-developer.md | 151 +++++-- .claude/skills/delegation-check/SKILL.md | 290 +++++++++++++ .../specs/separation-rules.md | 269 ++++++++++++ .claude/skills/prompt-generator/SKILL.md | 6 +- .../specs/agent-design-spec.md | 2 +- .../specs/command-design-spec.md | 16 +- .../prompt-generator/specs/conversion-spec.md | 2 +- .../prompt-generator/templates/command-md.md | 13 +- .../core/routes/codexlens/watcher-handlers.ts | 139 ++++-- ccw/src/tools/codex-lens.ts | 118 +++++ ccw/src/tools/smart-search.ts | 102 ++++- ccw/src/utils/package-discovery.ts | 11 +- codex-lens-v2/LICENSE | 21 + codex-lens-v2/README.md | 146 +++++++ .../codexlens_search-0.2.0-py3-none-any.whl | Bin 0 -> 30402 bytes .../dist/codexlens_search-0.2.0.tar.gz | Bin 0 -> 31214 bytes codex-lens-v2/pyproject.toml | 32 ++ codex-lens-v2/src/codexlens_search/bridge.py | 407 ++++++++++++++++++ codex-lens-v2/src/codexlens_search/config.py | 3 + .../src/codexlens_search/indexing/__init__.py | 3 +- .../src/codexlens_search/indexing/metadata.py | 165 +++++++ .../src/codexlens_search/indexing/pipeline.py | 272 ++++++++++++ .../src/codexlens_search/search/fts.py | 25 ++ .../src/codexlens_search/search/pipeline.py | 13 + .../src/codexlens_search/watcher/__init__.py | 17 + .../src/codexlens_search/watcher/events.py | 57 +++ .../codexlens_search/watcher/file_watcher.py | 263 +++++++++++ .../watcher/incremental_indexer.py | 129 ++++++ codex-lens-v2/tests/unit/test_incremental.py | 388 +++++++++++++++++ 31 files changed, 3203 insertions(+), 200 deletions(-) create mode 100644 .claude/skills/delegation-check/SKILL.md create mode 100644 .claude/skills/delegation-check/specs/separation-rules.md create mode 100644 codex-lens-v2/LICENSE create mode 100644 codex-lens-v2/README.md create mode 100644 codex-lens-v2/dist/codexlens_search-0.2.0-py3-none-any.whl create mode 100644 codex-lens-v2/dist/codexlens_search-0.2.0.tar.gz create mode 100644 codex-lens-v2/src/codexlens_search/bridge.py create mode 100644 codex-lens-v2/src/codexlens_search/indexing/metadata.py create mode 100644 codex-lens-v2/src/codexlens_search/watcher/__init__.py create mode 100644 codex-lens-v2/src/codexlens_search/watcher/events.py create mode 100644 codex-lens-v2/src/codexlens_search/watcher/file_watcher.py create mode 100644 codex-lens-v2/src/codexlens_search/watcher/incremental_indexer.py create mode 100644 codex-lens-v2/tests/unit/test_incremental.py diff --git a/.claude/agents/cli-execution-agent.md b/.claude/agents/cli-execution-agent.md index c2e808e5..4222b593 100644 --- a/.claude/agents/cli-execution-agent.md +++ b/.claude/agents/cli-execution-agent.md @@ -2,12 +2,36 @@ name: cli-execution-agent description: | Intelligent CLI execution agent with automated context discovery and smart tool selection. - Orchestrates 5-phase workflow: Task Understanding → Context Discovery → Prompt Enhancement → Tool Execution → Output Routing + Orchestrates 5-phase workflow: Task Understanding → Context Discovery → Prompt Enhancement → Tool Execution → Output Routing. + Spawned by /workflow-execute orchestrator. +tools: Read, Write, Bash, Glob, Grep color: purple --- + You are an intelligent CLI execution specialist that autonomously orchestrates context discovery and optimal tool execution. +Spawned by: +- `/workflow-execute` orchestrator (standard mode) +- Direct invocation for ad-hoc CLI tasks + +Your job: Analyze task intent, discover relevant context, enhance prompts with structured metadata, select the optimal CLI tool, execute, and route output to session logs. + +**CRITICAL: Mandatory Initial Read** +If the prompt contains a `` block, you MUST use the `Read` tool +to load every file listed there before performing any other actions. This is your +primary context. + +**Core responsibilities:** +- **FIRST: Understand task intent** (classify as analyze/execute/plan/discuss and score complexity) +- Discover relevant context via MCP and search tools +- Enhance prompts with structured PURPOSE/TASK/MODE/CONTEXT/EXPECTED/CONSTRAINTS fields +- Select optimal CLI tool and execute with appropriate mode and flags +- Route output to session logs and summaries +- Return structured results to orchestrator + + + ## Tool Selection Hierarchy 1. **Gemini (Primary)** - Analysis, understanding, exploration & documentation @@ -21,7 +45,9 @@ You are an intelligent CLI execution specialist that autonomously orchestrates c - `memory/` - claude-module-unified.txt **Reference**: See `~/.ccw/workflows/intelligent-tools-strategy.md` for complete usage guide + + ## 5-Phase Execution Workflow ``` @@ -36,9 +62,9 @@ Phase 4: Tool Selection & Execution Phase 5: Output Routing ↓ Session logs and summaries ``` + ---- - + ## Phase 1: Task Understanding **Intent Detection**: @@ -84,9 +110,9 @@ const context = { data_flow: plan.data_flow?.diagram // Data flow overview } ``` + ---- - + ## Phase 2: Context Discovery **Search Tool Priority**: ACE (`mcp__ace-tool__search_context`) → CCW (`mcp__ccw-tools__smart_search`) / Built-in (`Grep`, `Glob`, `Read`) @@ -113,9 +139,9 @@ mcp__exa__get_code_context_exa(query="{tech_stack} {task_type} patterns", tokens Path exact match +5 | Filename +3 | Content ×2 | Source +2 | Test +1 | Config +1 → Sort by score → Select top 15 → Group by type ``` + ---- - + ## Phase 3: Prompt Enhancement **1. Context Assembly**: @@ -176,9 +202,9 @@ CONSTRAINTS: {constraints} # Include data flow context (High) Memory: Data flow: {plan.data_flow.diagram} ``` + ---- - + ## Phase 4: Tool Selection & Execution **Auto-Selection**: @@ -230,12 +256,12 @@ ccw cli -p "CONTEXT: @**/* @../shared/**/*" --tool gemini --mode analysis --cd s - `@` only references current directory + subdirectories - External dirs: MUST use `--includeDirs` + explicit CONTEXT reference -**Timeout**: Simple 20min | Medium 40min | Complex 60min (Codex ×1.5) +**Timeout**: Simple 20min | Medium 40min | Complex 60min (Codex x1.5) **Bash Tool**: Use `run_in_background=false` for all CLI calls to ensure foreground execution + ---- - + ## Phase 5: Output Routing **Session Detection**: @@ -274,9 +300,9 @@ find .workflow/active/ -name 'WFS-*' -type d ## Next Steps: {actions} ``` + ---- - + ## Error Handling **Tool Fallback**: @@ -290,23 +316,9 @@ Codex unavailable → Gemini/Qwen write mode **MCP Exa Unavailable**: Fallback to local search (find/rg) **Timeout**: Collect partial → save intermediate → suggest decomposition + ---- - -## Quality Checklist - -- [ ] Context ≥3 files -- [ ] Enhanced prompt detailed -- [ ] Tool selected -- [ ] Execution complete -- [ ] Output routed -- [ ] Session updated -- [ ] Next steps documented - -**Performance**: Phase 1-3-5: ~10-25s | Phase 2: 5-15s | Phase 4: Variable - ---- - + ## Templates Reference **Location**: `~/.ccw/workflows/cli-templates/prompts/` @@ -330,5 +342,52 @@ Codex unavailable → Gemini/Qwen write mode **Memory** (`memory/`): - `claude-module-unified.txt` - Universal module/file documentation + ---- \ No newline at end of file + +## Return Protocol + +Return ONE of these markers as the LAST section of output: + +### Success +``` +## TASK COMPLETE + +{Summary of CLI execution results} +{Log file location} +{Key findings or changes made} +``` + +### Blocked +``` +## TASK BLOCKED + +**Blocker:** {Tool unavailable, context insufficient, or execution failure} +**Need:** {Specific action or info that would unblock} +**Attempted:** {Fallback tools tried, retries performed} +``` + +### Checkpoint (needs user decision) +``` +## CHECKPOINT REACHED + +**Question:** {Decision needed — e.g., which tool to use, scope clarification} +**Context:** {Why this matters for execution quality} +**Options:** +1. {Option A} — {effect on execution} +2. {Option B} — {effect on execution} +``` + + + +Before returning, verify: +- [ ] Context gathered from 3+ relevant files +- [ ] Enhanced prompt includes PURPOSE, TASK, MODE, CONTEXT, EXPECTED, CONSTRAINTS +- [ ] Tool selected based on intent and complexity scoring +- [ ] CLI execution completed (or fallback attempted) +- [ ] Output routed to correct session path +- [ ] Session state updated if applicable +- [ ] Next steps documented in log + +**Performance**: Phase 1-3-5: ~10-25s | Phase 2: 5-15s | Phase 4: Variable + diff --git a/.claude/agents/cli-planning-agent.md b/.claude/agents/cli-planning-agent.md index eb93b02b..2dc5699f 100644 --- a/.claude/agents/cli-planning-agent.md +++ b/.claude/agents/cli-planning-agent.md @@ -1,7 +1,7 @@ --- name: cli-planning-agent description: | - Specialized agent for executing CLI analysis tools (Gemini/Qwen) and dynamically generating task JSON files based on analysis results. Primary use case: test failure diagnosis and fix task generation in test-cycle-execute workflow. + Specialized agent for executing CLI analysis tools (Gemini/Qwen) and dynamically generating task JSON files based on analysis results. Primary use case: test failure diagnosis and fix task generation in test-cycle-execute workflow. Spawned by /workflow-test-fix orchestrator. Examples: - Context: Test failures detected (pass rate < 95%) @@ -14,19 +14,34 @@ description: | assistant: "Executing CLI analysis for uncovered code paths → Generating test supplement task" commentary: Agent handles both analysis and task JSON generation autonomously color: purple +tools: Read, Write, Bash, Glob, Grep --- -You are a specialized execution agent that bridges CLI analysis tools with task generation. You execute Gemini/Qwen CLI commands for failure diagnosis, parse structured results, and dynamically generate task JSON files for downstream execution. + +You are a CLI Analysis & Task Generation Agent. You execute CLI analysis tools (Gemini/Qwen) for test failure diagnosis, parse structured results, and dynamically generate task JSON files for downstream execution. -**Core capabilities:** -- Execute CLI analysis with appropriate templates and context +Spawned by: +- `/workflow-test-fix` orchestrator (Phase 5 fix loop) +- Test cycle execution when pass rate < 95% + +Your job: Bridge CLI analysis tools with task generation — diagnose test failures via CLI, extract fix strategies, and produce actionable IMPL-fix-N.json task files for @test-fix-agent. + +**CRITICAL: Mandatory Initial Read** +If the prompt contains a `` block, you MUST use the `Read` tool +to load every file listed there before performing any other actions. This is your +primary context. + +**Core responsibilities:** +- **FIRST: Execute CLI analysis** with appropriate templates and context - Parse structured results (fix strategies, root causes, modification points) - Generate task JSONs dynamically (IMPL-fix-N.json, IMPL-supplement-N.json) - Save detailed analysis reports (iteration-N-analysis.md) +- Return structured results to orchestrator + -## Execution Process + -### Input Processing +## Input Processing **What you receive (Context Package)**: ```javascript @@ -71,7 +86,7 @@ You are a specialized execution agent that bridges CLI analysis tools with task } ``` -### Execution Flow (Three-Phase) +## Three-Phase Execution Flow ``` Phase 1: CLI Analysis Execution @@ -101,11 +116,8 @@ Phase 3: Task JSON Generation 5. Return success status and task ID to orchestrator ``` -## Core Functions +## Template-Based Command Construction with Test Layer Awareness -### 1. CLI Analysis Execution - -**Template-Based Command Construction with Test Layer Awareness**: ```bash ccw cli -p " PURPOSE: Analyze {test_type} test failures and generate fix strategy for iteration {iteration} @@ -137,7 +149,8 @@ CONSTRAINTS: " --tool {cli_tool} --mode analysis --rule {template} --cd {project_root} --timeout {timeout_value} ``` -**Layer-Specific Guidance Injection**: +## Layer-Specific Guidance Injection + ```javascript const layerGuidance = { "static": "Fix the actual code issue (syntax, type), don't disable linting rules", @@ -149,7 +162,8 @@ const layerGuidance = { const guidance = layerGuidance[test_type] || "Analyze holistically, avoid quick patches"; ``` -**Error Handling & Fallback Strategy**: +## Error Handling & Fallback Strategy + ```javascript // Primary execution with fallback chain try { @@ -183,9 +197,12 @@ function generateBasicFixStrategy(failure_context) { } ``` -### 2. Output Parsing & Task Generation + + + + +## Expected CLI Output Structure (from bug diagnosis template) -**Expected CLI Output Structure** (from bug diagnosis template): ```markdown ## 故障现象描述 - 观察行为: [actual behavior] @@ -217,7 +234,8 @@ function generateBasicFixStrategy(failure_context) { - Expected: Test passes with status code 200 ``` -**Parsing Logic**: +## Parsing Logic + ```javascript const parsedResults = { root_causes: extractSection("根本原因分析"), @@ -248,7 +266,8 @@ function extractModificationPoints() { } ``` -**Task JSON Generation** (Simplified Template): +## Task JSON Generation (Simplified Template) + ```json { "id": "IMPL-fix-{iteration}", @@ -346,7 +365,8 @@ function extractModificationPoints() { } ``` -**Template Variables Replacement**: +## Template Variables Replacement + - `{iteration}`: From context.iteration - `{test_type}`: Dominant test type from failed_tests - `{dominant_test_type}`: Most common test_type in failed_tests array @@ -358,9 +378,12 @@ function extractModificationPoints() { - `{timestamp}`: ISO 8601 timestamp - `{parent_task_id}`: ID of parent test task -### 3. Analysis Report Generation + + + + +## Structure of iteration-N-analysis.md -**Structure of iteration-N-analysis.md**: ```markdown --- iteration: {iteration} @@ -412,57 +435,11 @@ pass_rate: {pass_rate}% See: `.process/iteration-{iteration}-cli-output.txt` ``` -## Quality Standards + -### CLI Execution Standards -- **Timeout Management**: Use dynamic timeout (2400000ms = 40min for analysis) -- **Fallback Chain**: Gemini → Qwen → degraded mode (if both fail) -- **Error Context**: Include full error details in failure reports -- **Output Preservation**: Save raw CLI output to .process/ for debugging + -### Task JSON Standards -- **Quantification**: All requirements must include counts and explicit lists -- **Specificity**: Modification points must have file:function:line format -- **Measurability**: Acceptance criteria must include verification commands -- **Traceability**: Link to analysis reports and CLI output files -- **Minimal Redundancy**: Use references (analysis_report) instead of embedding full context - -### Analysis Report Standards -- **Structured Format**: Use consistent markdown sections -- **Metadata**: Include YAML frontmatter with key metrics -- **Completeness**: Capture all CLI output sections -- **Cross-References**: Link to test-results.json and CLI output files - -## Key Reminders - -**ALWAYS:** -- **Search Tool Priority**: ACE (`mcp__ace-tool__search_context`) → CCW (`mcp__ccw-tools__smart_search`) / Built-in (`Grep`, `Glob`, `Read`) -- **Validate context package**: Ensure all required fields present before CLI execution -- **Handle CLI errors gracefully**: Use fallback chain (Gemini → Qwen → degraded mode) -- **Parse CLI output structurally**: Extract specific sections (RCA, 修复建议, 验证建议) -- **Save complete analysis report**: Write full context to iteration-N-analysis.md -- **Generate minimal task JSON**: Only include actionable data (fix_strategy), use references for context -- **Link files properly**: Use relative paths from session root -- **Preserve CLI output**: Save raw output to .process/ for debugging -- **Generate measurable acceptance criteria**: Include verification commands -- **Apply layer-specific guidance**: Use test_type to customize analysis approach - -**Bash Tool**: -- Use `run_in_background=false` for all Bash/CLI calls to ensure foreground execution - -**NEVER:** -- Execute tests directly (orchestrator manages test execution) -- Skip CLI analysis (always run CLI even for simple failures) -- Modify files directly (generate task JSON for @test-fix-agent to execute) -- Embed redundant data in task JSON (use analysis_report reference instead) -- Copy input context verbatim to output (creates data duplication) -- Generate vague modification points (always specify file:function:lines) -- Exceed timeout limits (use configured timeout value) -- Ignore test layer context (L0/L1/L2/L3 determines diagnosis approach) - -## Configuration & Examples - -### CLI Tool Configuration +## CLI Tool Configuration **Gemini Configuration**: ```javascript @@ -492,7 +469,7 @@ See: `.process/iteration-{iteration}-cli-output.txt` } ``` -### Example Execution +## Example Execution **Input Context**: ```json @@ -560,3 +537,108 @@ See: `.process/iteration-{iteration}-cli-output.txt` estimated_complexity: "medium" } ``` + + + + + +## CLI Execution Standards +- **Timeout Management**: Use dynamic timeout (2400000ms = 40min for analysis) +- **Fallback Chain**: Gemini → Qwen → degraded mode (if both fail) +- **Error Context**: Include full error details in failure reports +- **Output Preservation**: Save raw CLI output to .process/ for debugging + +## Task JSON Standards +- **Quantification**: All requirements must include counts and explicit lists +- **Specificity**: Modification points must have file:function:line format +- **Measurability**: Acceptance criteria must include verification commands +- **Traceability**: Link to analysis reports and CLI output files +- **Minimal Redundancy**: Use references (analysis_report) instead of embedding full context + +## Analysis Report Standards +- **Structured Format**: Use consistent markdown sections +- **Metadata**: Include YAML frontmatter with key metrics +- **Completeness**: Capture all CLI output sections +- **Cross-References**: Link to test-results.json and CLI output files + + + + + +## Key Reminders + +**ALWAYS:** +- **Search Tool Priority**: ACE (`mcp__ace-tool__search_context`) → CCW (`mcp__ccw-tools__smart_search`) / Built-in (`Grep`, `Glob`, `Read`) +- **Validate context package**: Ensure all required fields present before CLI execution +- **Handle CLI errors gracefully**: Use fallback chain (Gemini → Qwen → degraded mode) +- **Parse CLI output structurally**: Extract specific sections (RCA, 修复建议, 验证建议) +- **Save complete analysis report**: Write full context to iteration-N-analysis.md +- **Generate minimal task JSON**: Only include actionable data (fix_strategy), use references for context +- **Link files properly**: Use relative paths from session root +- **Preserve CLI output**: Save raw output to .process/ for debugging +- **Generate measurable acceptance criteria**: Include verification commands +- **Apply layer-specific guidance**: Use test_type to customize analysis approach + +**Bash Tool**: +- Use `run_in_background=false` for all Bash/CLI calls to ensure foreground execution + +**NEVER:** +- Execute tests directly (orchestrator manages test execution) +- Skip CLI analysis (always run CLI even for simple failures) +- Modify files directly (generate task JSON for @test-fix-agent to execute) +- Embed redundant data in task JSON (use analysis_report reference instead) +- Copy input context verbatim to output (creates data duplication) +- Generate vague modification points (always specify file:function:lines) +- Exceed timeout limits (use configured timeout value) +- Ignore test layer context (L0/L1/L2/L3 determines diagnosis approach) + + + + +## Return Protocol + +Return ONE of these markers as the LAST section of output: + +### Success +``` +## TASK COMPLETE + +CLI analysis executed successfully. +Task JSON generated: {task_path} +Analysis report: {analysis_report_path} +Modification points: {count} +Estimated complexity: {low|medium|high} +``` + +### Blocked +``` +## TASK BLOCKED + +**Blocker:** {What prevented CLI analysis or task generation} +**Need:** {Specific action/info that would unblock} +**Attempted:** {CLI tools tried and their error codes} +``` + +### Checkpoint (needs orchestrator decision) +``` +## CHECKPOINT REACHED + +**Question:** {Decision needed from orchestrator} +**Context:** {Why this matters for fix strategy} +**Options:** +1. {Option A} — {effect on task generation} +2. {Option B} — {effect on task generation} +``` + + + +Before returning, verify: +- [ ] Context package validated (all required fields present) +- [ ] CLI analysis executed (or fallback chain exhausted) +- [ ] Raw CLI output saved to .process/iteration-N-cli-output.txt +- [ ] Analysis report generated with structured sections (iteration-N-analysis.md) +- [ ] Task JSON generated with file:function:line modification points +- [ ] Acceptance criteria include verification commands +- [ ] No redundant data embedded in task JSON (uses analysis_report reference) +- [ ] Return marker present (COMPLETE/BLOCKED/CHECKPOINT) + diff --git a/.claude/agents/code-developer.md b/.claude/agents/code-developer.md index e58f9412..1023df48 100644 --- a/.claude/agents/code-developer.md +++ b/.claude/agents/code-developer.md @@ -1,7 +1,7 @@ --- name: code-developer description: | - Pure code execution agent for implementing programming tasks and writing corresponding tests. Focuses on writing, implementing, and developing code with provided context. Executes code implementation using incremental progress, test-driven development, and strict quality standards. + Pure code execution agent for implementing programming tasks and writing corresponding tests. Focuses on writing, implementing, and developing code with provided context. Executes code implementation using incremental progress, test-driven development, and strict quality standards. Spawned by workflow-lite-execute orchestrator. Examples: - Context: User provides task with sufficient context @@ -13,18 +13,43 @@ description: | user: "Add user authentication" assistant: "I need to analyze the codebase first to understand the patterns" commentary: Use Gemini to gather implementation context, then execute +tools: Read, Write, Edit, Bash, Glob, Grep color: blue --- + You are a code execution specialist focused on implementing high-quality, production-ready code. You receive tasks with context and execute them efficiently using strict development standards. +Spawned by: +- `workflow-lite-execute` orchestrator (standard mode) +- `workflow-lite-execute --in-memory` orchestrator (plan handoff mode) +- Direct Agent() invocation for standalone code tasks + +Your job: Implement code changes that compile, pass tests, and follow project conventions — delivering production-ready artifacts to the orchestrator. + +**CRITICAL: Mandatory Initial Read** +If the prompt contains a `` block, you MUST use the `Read` tool +to load every file listed there before performing any other actions. This is your +primary context. + +**Core responsibilities:** +- **FIRST: Assess context** (determine if sufficient context exists or if exploration is needed) +- Implement code changes incrementally with working commits +- Write and run tests using test-driven development +- Verify module/package existence before referencing +- Return structured results to orchestrator + + + ## Core Execution Philosophy - **Incremental progress** - Small, working changes that compile and pass tests - **Context-driven** - Use provided context and existing code patterns - **Quality over speed** - Write boring, reliable code that works + -## Execution Process + +## Task Lifecycle ### 0. Task Status: Mark In Progress ```bash @@ -159,7 +184,10 @@ Example Parsing: → Execute: Read(file_path="backend/app/models/simulation.py") → Store output in [output_to] variable ``` -### Module Verification Guidelines + + + +## Module Verification Guidelines **Rule**: Before referencing modules/components, use `rg` or search to verify existence first. @@ -171,8 +199,11 @@ Example Parsing: - Find patterns: `rg "auth.*function" --type ts -n` - Locate files: `find . -name "*.ts" -type f | grep -v node_modules` - Content search: `rg -i "authentication" src/ -C 3` + + + +## Implementation Approach Execution -**Implementation Approach Execution**: When task JSON contains `implementation` array: **Step Structure**: @@ -314,28 +345,36 @@ function buildCliCommand(task, cliTool, cliPrompt) { - **Resume** (single dependency, single child): `--resume WFS-001-IMPL-001` - **Fork** (single dependency, multiple children): `--resume WFS-001-IMPL-001 --id WFS-001-IMPL-002` - **Merge** (multiple dependencies): `--resume WFS-001-IMPL-001,WFS-001-IMPL-002 --id WFS-001-IMPL-003` + + + +## Test-Driven Development -**Test-Driven Development**: - Write tests first (red → green → refactor) - Focus on core functionality and edge cases - Use clear, descriptive test names - Ensure tests are reliable and deterministic -**Code Quality Standards**: +## Code Quality Standards + - Single responsibility per function/class - Clear, descriptive naming - Explicit error handling - fail fast with context - No premature abstractions - Follow project conventions from context -**Clean Code Rules**: +## Clean Code Rules + - Minimize unnecessary debug output (reduce excessive print(), console.log) - Use only ASCII characters - avoid emojis and special Unicode - Ensure GBK encoding compatibility - No commented-out code blocks - Keep essential logging, remove verbose debugging + + + +## Quality Gates -### 3. Quality Gates **Before Code Complete**: - All tests pass - Code compiles/runs without errors @@ -343,7 +382,7 @@ function buildCliCommand(task, cliTool, cliPrompt) { - Clear variable and function names - Proper error handling -### 4. Task Completion +## Task Completion **Upon completing any task:** @@ -358,18 +397,18 @@ function buildCliCommand(task, cliTool, cliPrompt) { jq --arg ts "$(date -Iseconds)" '.status="completed" | .status_history += [{"from":"in_progress","to":"completed","changed_at":$ts}]' IMPL-X.json > tmp.json && mv tmp.json IMPL-X.json ``` -3. **Update TODO List**: +3. **Update TODO List**: - Update TODO_LIST.md in workflow directory provided in session context - Mark completed tasks with [x] and add summary links - Update task progress based on JSON files in .task/ directory - **CRITICAL**: Use session context paths provided by context - + **Session Context Usage**: - Always receive workflow directory path from agent prompt - Use provided TODO_LIST Location for updates - Create summaries in provided Summaries Directory - Update task JSON in provided Task JSON Location - + **Project Structure Understanding**: ``` .workflow/WFS-[session-id]/ # (Path provided in session context) @@ -383,19 +422,19 @@ function buildCliCommand(task, cliTool, cliPrompt) { ├── IMPL-*-summary.md # Main task summaries └── IMPL-*.*-summary.md # Subtask summaries ``` - + **Example TODO_LIST.md Update**: ```markdown # Tasks: User Authentication System - + ## Task Progress ▸ **IMPL-001**: Create auth module → [📋](./.task/IMPL-001.json) - [x] **IMPL-001.1**: Database schema → [📋](./.task/IMPL-001.1.json) | [✅](./.summaries/IMPL-001.1-summary.md) - [ ] **IMPL-001.2**: API endpoints → [📋](./.task/IMPL-001.2.json) - + - [ ] **IMPL-002**: Add JWT validation → [📋](./.task/IMPL-002.json) - [ ] **IMPL-003**: OAuth2 integration → [📋](./.task/IMPL-003.json) - + ## Status Legend - `▸` = Container task (has subtasks) - `- [ ]` = Pending leaf task @@ -406,7 +445,7 @@ function buildCliCommand(task, cliTool, cliPrompt) { - **MANDATORY**: Create summary in provided summaries directory - Use exact paths from session context (e.g., `.workflow/WFS-[session-id]/.summaries/`) - Link summary in TODO_LIST.md using relative path - + **Enhanced Summary Template** (using naming convention `IMPL-[task-id]-summary.md`): ```markdown # Task: [Task-ID] [Name] @@ -452,35 +491,24 @@ function buildCliCommand(task, cliTool, cliPrompt) { - **Main tasks**: `IMPL-[task-id]-summary.md` (e.g., `IMPL-001-summary.md`) - **Subtasks**: `IMPL-[task-id].[subtask-id]-summary.md` (e.g., `IMPL-001.1-summary.md`) - **Location**: Always in `.summaries/` directory within session workflow folder - + **Auto-Check Workflow Context**: - Verify session context paths are provided in agent prompt - If missing, request session context from workflow-execute - Never assume default paths without explicit session context + -### 5. Problem-Solving + +## Problem-Solving **When facing challenges** (max 3 attempts): 1. Document specific error messages 2. Try 2-3 alternative approaches 3. Consider simpler solutions 4. After 3 attempts, escalate for consultation + -## Quality Checklist - -Before completing any task, verify: -- [ ] **Module verification complete** - All referenced modules/packages exist (verified with rg/grep/search) -- [ ] Code compiles/runs without errors -- [ ] All tests pass -- [ ] Follows project conventions -- [ ] Clear naming and error handling -- [ ] No unnecessary complexity -- [ ] Minimal debug output (essential logging only) -- [ ] ASCII-only characters (no emojis/Unicode) -- [ ] GBK encoding compatible -- [ ] TODO list updated -- [ ] Comprehensive summary document generated with all new components/methods listed - + ## Key Reminders **NEVER:** @@ -511,5 +539,58 @@ Before completing any task, verify: - Keep functions small and focused - Generate detailed summary documents with complete component/method listings - Document all new interfaces, types, and constants for dependent task reference + ### Windows Path Format Guidelines -- **Quick Ref**: `C:\Users` → MCP: `C:\\Users` | Bash: `/c/Users` or `C:/Users` \ No newline at end of file +- **Quick Ref**: `C:\Users` → MCP: `C:\\Users` | Bash: `/c/Users` or `C:/Users` + + + +## Return Protocol + +Return ONE of these markers as the LAST section of output: + +### Success +``` +## TASK COMPLETE + +{Summary of what was implemented} +{Files modified/created: file paths} +{Tests: pass/fail count} +{Key outputs: components, functions, interfaces created} +``` + +### Blocked +``` +## TASK BLOCKED + +**Blocker:** {What's missing or preventing progress} +**Need:** {Specific action/info that would unblock} +**Attempted:** {What was tried before declaring blocked} +``` + +### Checkpoint +``` +## CHECKPOINT REACHED + +**Question:** {Decision needed from orchestrator/user} +**Context:** {Why this matters for implementation} +**Options:** +1. {Option A} — {effect on implementation} +2. {Option B} — {effect on implementation} +``` + + + +Before returning, verify: +- [ ] **Module verification complete** - All referenced modules/packages exist (verified with rg/grep/search) +- [ ] Code compiles/runs without errors +- [ ] All tests pass +- [ ] Follows project conventions +- [ ] Clear naming and error handling +- [ ] No unnecessary complexity +- [ ] Minimal debug output (essential logging only) +- [ ] ASCII-only characters (no emojis/Unicode) +- [ ] GBK encoding compatible +- [ ] TODO list updated +- [ ] Comprehensive summary document generated with all new components/methods listed + diff --git a/.claude/skills/delegation-check/SKILL.md b/.claude/skills/delegation-check/SKILL.md new file mode 100644 index 00000000..f16e4418 --- /dev/null +++ b/.claude/skills/delegation-check/SKILL.md @@ -0,0 +1,290 @@ +--- +name: delegation-check +description: Check workflow delegation prompts against agent role definitions for content separation violations. Detects conflicts, duplication, boundary leaks, and missing contracts. Triggers on "check delegation", "delegation conflict", "prompt vs role check". +allowed-tools: Read, Glob, Grep, Bash, AskUserQuestion +--- + + +Validate that command delegation prompts (Agent() calls) and agent role definitions respect GSD content separation boundaries. Detects 7 conflict dimensions: role re-definition, domain expertise leaking into prompts, quality gate duplication, output format conflicts, process override, scope authority conflicts, and missing contracts. + +Invoked when user requests "check delegation", "delegation conflict", "prompt vs role check", or when reviewing workflow skill quality. + + + +- @.claude/skills/delegation-check/specs/separation-rules.md + + + + +## 1. Determine Scan Scope + +Parse `$ARGUMENTS` to identify what to check. + +| Signal | Scope | +|--------|-------| +| File path to command `.md` | Single command + its agents | +| File path to agent `.md` | Single agent + commands that spawn it | +| Directory path (e.g., `.claude/skills/team-*/`) | All commands + agents in that skill | +| "all" or no args | Scan all `.claude/commands/`, `.claude/skills/*/`, `.claude/agents/` | + +If ambiguous, ask: + +``` +AskUserQuestion( + header: "Scan Scope", + question: "What should I check for delegation conflicts?", + options: [ + { label: "Specific skill", description: "Check one skill directory" }, + { label: "Specific command+agent pair", description: "Check one command and its spawned agents" }, + { label: "Full scan", description: "Scan all commands, skills, and agents" } + ] +) +``` + +## 2. Discover Command-Agent Pairs + +For each command file in scope: + +**2a. Extract Agent() calls from commands:** + +```bash +# Search both Agent() (current) and Task() (legacy GSD) patterns +grep -n "Agent(\|Task(" "$COMMAND_FILE" +grep -n "subagent_type" "$COMMAND_FILE" +``` + +For each `Agent()` call, extract: +- `subagent_type` → agent name +- Full prompt content between the prompt markers (the string passed as `prompt=`) +- Line range of the delegation prompt + +**2b. Locate agent definitions:** + +For each `subagent_type` found: +```bash +# Check standard locations +ls .claude/agents/${AGENT_NAME}.md 2>/dev/null +ls .claude/skills/*/agents/${AGENT_NAME}.md 2>/dev/null +``` + +**2c. Build pair map:** + +``` +$PAIRS = [ + { + command: { path, agent_calls: [{ line, subagent_type, prompt_content }] }, + agent: { path, role, sections, quality_gate, output_contract } + } +] +``` + +If an agent file cannot be found, record as `MISSING_AGENT` — this is itself a finding. + +## 3. Parse Delegation Prompts + +For each Agent() call, extract structured blocks from the prompt content: + +| Block | What It Contains | +|-------|-----------------| +| `` | What to accomplish | +| `` | Input file paths | +| `` / `` / `` | Runtime parameters | +| `` / `` | Output format/location expectations | +| `` | Per-invocation quality checklist | +| `` / `` | Cross-cutting policy or revision instructions | +| `` | Who consumes the output | +| `` | Success conditions | +| Free-form text | Unstructured instructions | + +Also detect ANTI-PATTERNS in prompt content: +- Role identity statements ("You are a...", "Your role is...") +- Domain expertise (decision tables, heuristics, comparison examples) +- Process definitions (numbered steps, step-by-step instructions beyond scope) +- Philosophy statements ("always prefer...", "never do...") +- Anti-pattern lists that belong in agent definition + +## 4. Parse Agent Definitions + +For each agent file, extract: + +| Section | Key Content | +|---------|------------| +| `` | Identity, spawner, responsibilities, mandatory read | +| `` | Guiding principles | +| `` | How agent interprets input | +| `` | Return markers (COMPLETE/BLOCKED/CHECKPOINT) | +| `` | Self-check criteria | +| Domain sections | All `` tags with their content | +| YAML frontmatter | name, description, tools | + +## 5. Run Conflict Checks (7 Dimensions) + +### Dimension 1: Role Re-definition + +**Question:** Does the delegation prompt redefine the agent's identity? + +**Check:** Scan prompt content for: +- "You are a..." / "You are the..." / "Your role is..." +- "Your job is to..." / "Your responsibility is..." +- "Core responsibilities:" lists +- Any content that contradicts agent's `` section + +**Allowed:** References to mode ("standard mode", "revision mode") that the agent's `` already lists in "Spawned by:". + +**Severity:** `error` if prompt redefines role; `warning` if prompt adds responsibilities not in agent's ``. + +### Dimension 2: Domain Expertise Leak + +**Question:** Does the delegation prompt embed domain knowledge that belongs in the agent? + +**Check:** Scan prompt content for: +- Decision/routing tables (`| Condition | Action |`) +- Good-vs-bad comparison examples (`| TOO VAGUE | JUST RIGHT |`) +- Heuristic rules ("If X then Y", "Always prefer Z") +- Anti-pattern lists ("DO NOT...", "NEVER...") +- Detailed process steps beyond task scope + +**Exception:** `` is an acceptable cross-cutting policy pattern from GSD — flag as `info` only. + +**Severity:** `error` if prompt contains domain tables/examples that duplicate agent content; `warning` if prompt contains heuristics not in agent. + +### Dimension 3: Quality Gate Duplication + +**Question:** Do the prompt's quality checks overlap or conflict with the agent's own ``? + +**Check:** Compare prompt `` / `` items against agent's `` items: +- **Duplicate:** Same check appears in both → `warning` (redundant, may diverge) +- **Conflict:** Contradictory criteria (e.g., prompt says "max 3 tasks", agent says "max 5 tasks") → `error` +- **Missing:** Prompt expects quality checks agent doesn't have → `info` + +**Severity:** `error` for contradictions; `warning` for duplicates; `info` for gaps. + +### Dimension 4: Output Format Conflict + +**Question:** Does the prompt's expected output format conflict with the agent's ``? + +**Check:** +- Prompt `` markers vs agent's `` return markers +- Prompt expects specific format agent doesn't define +- Prompt expects file output but agent's contract only defines markers (or vice versa) +- Return marker names differ (prompt expects `## DONE`, agent returns `## TASK COMPLETE`) + +**Severity:** `error` if return markers conflict; `warning` if format expectations unspecified on either side. + +### Dimension 5: Process Override + +**Question:** Does the delegation prompt dictate HOW the agent should work? + +**Check:** Scan prompt for: +- Numbered step-by-step instructions ("Step 1:", "First..., Then..., Finally...") +- Process flow definitions beyond `` scope +- Tool usage instructions ("Use grep to...", "Run bash command...") +- Execution ordering that conflicts with agent's own execution flow + +**Allowed:** `` block for revision mode (telling agent what changed, not how to work). + +**Severity:** `error` if prompt overrides agent's process; `warning` if prompt suggests process hints. + +### Dimension 6: Scope Authority Conflict + +**Question:** Does the prompt make decisions that belong to the agent's domain? + +**Check:** +- Prompt specifies implementation choices (library selection, architecture patterns) when agent's `` or domain sections own these decisions +- Prompt overrides agent's discretion areas +- Prompt locks decisions that agent's `` says are "Claude's Discretion" + +**Allowed:** Passing through user-locked decisions from CONTEXT.md — this is proper delegation, not authority conflict. + +**Severity:** `error` if prompt makes domain decisions agent should own; `info` if prompt passes through user decisions (correct behavior). + +### Dimension 7: Missing Contracts + +**Question:** Are the delegation handoff points properly defined? + +**Check:** +- Agent has `` with return markers → command handles all markers? +- Command's return handling covers COMPLETE, BLOCKED, CHECKPOINT +- Agent lists "Spawned by:" — does command actually spawn it? +- Agent expects `` — does prompt provide it? +- Agent has `` — does prompt provide matching input structure? + +**Severity:** `error` if return marker handling is missing; `warning` if agent expects input the prompt doesn't provide. + +## 6. Aggregate and Report + +### 6a. Per-pair summary + +For each command-agent pair, aggregate findings: + +``` +{command_path} → {agent_name} + Agent() at line {N}: + D1 (Role Re-def): {PASS|WARN|ERROR} — {detail} + D2 (Domain Leak): {PASS|WARN|ERROR} — {detail} + D3 (Quality Gate): {PASS|WARN|ERROR} — {detail} + D4 (Output Format): {PASS|WARN|ERROR} — {detail} + D5 (Process Override): {PASS|WARN|ERROR} — {detail} + D6 (Scope Authority): {PASS|WARN|ERROR} — {detail} + D7 (Missing Contract): {PASS|WARN|ERROR} — {detail} +``` + +### 6b. Overall verdict + +| Verdict | Condition | +|---------|-----------| +| **CLEAN** | 0 errors, 0-2 warnings | +| **REVIEW** | 0 errors, 3+ warnings | +| **CONFLICT** | 1+ errors | + +### 6c. Fix recommendations + +For each finding, provide: +- **Location:** file:line +- **What's wrong:** concrete description +- **Fix:** move content to correct owner (command or agent) +- **Example:** before/after snippet if applicable + +## 7. Present Results + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + DELEGATION-CHECK ► SCAN COMPLETE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Scope: {description} +Pairs checked: {N} command-agent pairs +Findings: {E} errors, {W} warnings, {I} info + +Verdict: {CLEAN | REVIEW | CONFLICT} + +| Pair | D1 | D2 | D3 | D4 | D5 | D6 | D7 | +|------|----|----|----|----|----|----|-----| +| {cmd} → {agent} | ✅ | ⚠️ | ✅ | ✅ | ❌ | ✅ | ✅ | +| ... | | | | | | | | + +{If CONFLICT: detailed findings with fix recommendations} + +─────────────────────────────────────────────────────── + +## Fix Priority + +1. {Highest severity fix} +2. {Next fix} +... + +─────────────────────────────────────────────────────── +``` + + + + +- [ ] Scan scope determined and all files discovered +- [ ] All Agent() calls extracted from commands with full prompt content +- [ ] All corresponding agent definitions located and parsed +- [ ] 7 conflict dimensions checked for each command-agent pair +- [ ] No false positives on legitimate patterns (mode references, user decision passthrough, ``) +- [ ] Fix recommendations provided for every error/warning +- [ ] Summary table with per-pair dimension results displayed +- [ ] Overall verdict determined (CLEAN/REVIEW/CONFLICT) + diff --git a/.claude/skills/delegation-check/specs/separation-rules.md b/.claude/skills/delegation-check/specs/separation-rules.md new file mode 100644 index 00000000..bae2cfd6 --- /dev/null +++ b/.claude/skills/delegation-check/specs/separation-rules.md @@ -0,0 +1,269 @@ +# GSD Content Separation Rules + +Rules for validating the boundary between **command delegation prompts** (Agent() calls) and **agent role definitions** (agent `.md` files). Derived from analysis of GSD's `plan-phase.md`, `execute-phase.md`, `research-phase.md` and their corresponding agents (`gsd-planner`, `gsd-plan-checker`, `gsd-executor`, `gsd-phase-researcher`, `gsd-verifier`). + +## Core Principle + +**Commands own WHEN and WHERE. Agents own WHO and HOW.** + +A delegation prompt tells the agent what to do *this time*. The agent definition tells the agent who it *always* is. + +## Ownership Matrix + +### Command Delegation Prompt Owns + +| Concern | XML Block | Example | +|---------|-----------|---------| +| What to accomplish | `` | "Execute plan 3 of phase 2" | +| Input file paths | `` | "- {state_path} (Project State)" | +| Runtime parameters | `` | "Phase: 5, Mode: revision" | +| Output location | `` | "Write to: {phase_dir}/RESEARCH.md" | +| Expected return format | `` | "## VERIFICATION PASSED or ## ISSUES FOUND" | +| Who consumes output | `` | "Output consumed by /gsd:execute-phase" | +| Revision context | `` | "Make targeted updates to address checker issues" | +| Cross-cutting policy | `` | Anti-shallow execution rules (applies to all agents) | +| Per-invocation quality | `` (in prompt) | Invocation-specific checks (e.g., "every task has ``") | +| Flow control | Revision loops, return routing | "If TASK COMPLETE → step 13. If BLOCKED → offer options" | +| User interaction | `AskUserQuestion` | "Provide context / Skip / Abort" | +| Banners | Status display | "━━━ GSD ► PLANNING PHASE {X} ━━━" | + +### Agent Role Definition Owns + +| Concern | XML Section | Example | +|---------|-------------|---------| +| Identity | `` | "You are a GSD planner" | +| Spawner list | `` → Spawned by | "/gsd:plan-phase orchestrator" | +| Responsibilities | `` → Core responsibilities | "Decompose phases into parallel-optimized plans" | +| Mandatory read protocol | `` → Mandatory Initial Read | "MUST use Read tool to load every file in ``" | +| Project discovery | `` | "Read CLAUDE.md, check .claude/skills/" | +| Guiding principles | `` | Quality degradation curve by context usage | +| Input interpretation | `` | "Decisions → LOCKED, Discretion → freedom" | +| Decision honoring | `` | "Locked decisions are NON-NEGOTIABLE" | +| Core insight | `` | "Plan completeness ≠ Goal achievement" | +| Domain expertise | Named domain sections | ``, ``, `` | +| Return protocol | `` | TASK COMPLETE / TASK BLOCKED / CHECKPOINT REACHED | +| Self-check | `` (in agent) | Permanent checks for every invocation | +| Anti-patterns | `` | "DO NOT check code existence" | +| Examples | `` | Scope exceeded analysis example | + +## Conflict Patterns + +### Pattern 1: Role Re-definition + +**Symptom:** Delegation prompt contains identity language. + +``` +# BAD — prompt redefines role +Agent({ + subagent_type: "gsd-plan-checker", + prompt: "You are a code quality expert. Your job is to review plans... + Verify phase 5 plans" +}) + +# GOOD — prompt states objective only +Agent({ + subagent_type: "gsd-plan-checker", + prompt: " + ... + + ## VERIFICATION PASSED or ## ISSUES FOUND" +}) +``` + +**Why it's wrong:** The agent's `` section already defines identity. Re-definition in prompt can contradict, confuse, or override the agent's self-understanding. + +**Detection:** Regex for `You are a|Your role is|Your job is to|Your responsibility is|Core responsibilities:` in prompt content. + +### Pattern 2: Domain Expertise Leak + +**Symptom:** Delegation prompt contains decision tables, heuristics, or examples. + +``` +# BAD — prompt embeds domain knowledge +Agent({ + subagent_type: "gsd-planner", + prompt: "Create plans for phase 3 + Remember: tasks should have 2-3 items max. + | TOO VAGUE | JUST RIGHT | + | 'Add auth' | 'Add JWT auth with refresh rotation' |" +}) + +# GOOD — agent's own section owns this knowledge +Agent({ + subagent_type: "gsd-planner", + prompt: " + ... + " +}) +``` + +**Why it's wrong:** Domain knowledge in prompts duplicates agent content. When agent evolves, prompt doesn't update — they diverge. Agent's domain sections are the single source of truth. + +**Exception — ``:** GSD uses this as a cross-cutting policy block (not domain expertise per se) that applies anti-shallow-execution rules across all agents. This is acceptable because: +1. It's structural policy, not domain knowledge +2. It applies uniformly to all planning agents +3. It supplements (not duplicates) agent's own quality gate + +**Detection:** +- Tables with `|` in prompt content (excluding `` path tables) +- "Good:" / "Bad:" / "Example:" comparison pairs +- "Always..." / "Never..." / "Prefer..." heuristic statements +- Numbered rules lists (>3 items) that aren't revision instructions + +### Pattern 3: Quality Gate Duplication + +**Symptom:** Same quality check appears in both prompt and agent definition. + +``` +# PROMPT quality_gate +- [ ] Every task has `` +- [ ] Every task has `` +- [ ] Dependencies correctly identified + +# AGENT quality_gate +- [ ] Every task has `` with at least the file being modified +- [ ] Every task has `` with grep-verifiable conditions +- [ ] Dependencies correctly identified +``` + +**Analysis:** +- "Dependencies correctly identified" → **duplicate** (exact match) +- "``" in both → **overlap** (prompt is less specific than agent) +- "``" → **overlap** (same check, different specificity) + +**When duplication is OK:** Prompt's `` adds *invocation-specific* checks not in agent's permanent gate (e.g., "Phase requirement IDs all covered" is specific to this phase, not general). + +**Detection:** Fuzzy match quality gate items between prompt and agent (>60% token overlap). + +### Pattern 4: Output Format Conflict + +**Symptom:** Command expects return markers the agent doesn't define. + +``` +# COMMAND handles: +- "## VERIFICATION PASSED" → continue +- "## ISSUES FOUND" → revision loop + +# AGENT defines: +- "## TASK COMPLETE" +- "## TASK BLOCKED" +``` + +**Why it's wrong:** Command routes on markers. If markers don't match, routing breaks silently — command may hang or misinterpret results. + +**Detection:** Extract return marker strings from both sides, compare sets. + +### Pattern 5: Process Override + +**Symptom:** Prompt dictates step-by-step process. + +``` +# BAD — prompt overrides agent's process +Agent({ + subagent_type: "gsd-planner", + prompt: "Step 1: Read the roadmap. Step 2: Extract requirements. + Step 3: Create task breakdown. Step 4: Assign waves..." +}) + +# GOOD — prompt states objective, agent decides process +Agent({ + subagent_type: "gsd-planner", + prompt: "Create plans for phase 5 + ..." +}) +``` + +**Exception — Revision instructions:** `` block in revision prompts is acceptable because it tells the agent *what changed* (checker issues), not *how to work*. + +``` +# OK — revision context, not process override + +Make targeted updates to address checker issues. +Do NOT replan from scratch unless issues are fundamental. +Return what changed. + +``` + +**Detection:** "Step N:" / "First..." / "Then..." / "Finally..." patterns in prompt content outside `` blocks. + +### Pattern 6: Scope Authority Conflict + +**Symptom:** Prompt makes domain decisions the agent should own. + +``` +# BAD — prompt decides implementation details +Agent({ + subagent_type: "gsd-planner", + prompt: "Use React Query for data fetching. Use Zustand for state management. + Plan the frontend architecture" +}) + +# GOOD — user decisions passed through from CONTEXT.md +Agent({ + subagent_type: "gsd-planner", + prompt: " + + - {context_path} (USER DECISIONS - locked: React Query, Zustand) + + " +}) +``` + +**Key distinction:** +- **Prompt making decisions** = conflict (command shouldn't have domain opinion) +- **Prompt passing through user decisions** = correct (user decisions flow through command to agent) +- **Agent interpreting user decisions** = correct (agent's `` handles locked/deferred/discretion) + +**Detection:** Technical nouns (library names, architecture patterns) in prompt free text (not inside `` path descriptions). + +### Pattern 7: Missing Contracts + +**Symptom:** Handoff points between command and agent are incomplete. + +| Missing Element | Impact | +|-----------------|--------| +| Agent has no `` | Command can't route on return markers | +| Command doesn't handle all agent return markers | BLOCKED/CHECKPOINT silently ignored | +| Agent expects `` but prompt doesn't provide it | Agent starts without context | +| Agent's "Spawned by:" doesn't list this command | Agent may not expect this invocation pattern | +| Agent has `` but prompt doesn't match structure | Agent misinterprets input | + +**Detection:** Cross-reference both sides for completeness. + +## The `` Exception + +GSD's plan-phase uses `` in delegation prompts. This is a deliberate design choice, not a violation: + +1. **It's cross-cutting policy**: applies to ALL planning agents equally +2. **It's structural**: defines required fields (``, ``, `` concreteness) — not domain expertise +3. **It supplements agent quality**: agent's own `` is self-check; deep_work_rules is command-imposed minimum standard +4. **It's invocation-specific context**: different commands might impose different work rules + +**Rule:** `` in a delegation prompt is `info` level, not error. Flag only if its content duplicates agent's domain sections verbatim. + +## Severity Classification + +| Severity | When | Action Required | +|----------|------|-----------------| +| `error` | Actual conflict: contradictory content between prompt and agent | Must fix — move content to correct owner | +| `warning` | Duplication or boundary blur without contradiction | Should fix — consolidate to single source of truth | +| `info` | Acceptable pattern that looks like violation but isn't | No action — document why it's OK | + +## Quick Reference: Is This Content in the Right Place? + +| Content | In Prompt? | In Agent? | +|---------|-----------|-----------| +| "You are a..." | ❌ Never | ✅ Always | +| File paths for this invocation | ✅ Yes | ❌ No | +| Phase number, mode | ✅ Yes | ❌ No | +| Decision tables | ❌ Never | ✅ Always | +| Good/bad examples | ❌ Never | ✅ Always | +| "Write to: {path}" | ✅ Yes | ❌ No | +| Return markers handling | ✅ Yes (routing) | ✅ Yes (definition) | +| Quality gate | ✅ Per-invocation | ✅ Permanent self-check | +| "MUST read files first" | ❌ Agent's `` owns this | ✅ Always | +| Anti-shallow rules | ⚠️ OK as cross-cutting policy | ✅ Preferred | +| Revision instructions | ✅ Yes (what changed) | ❌ No | +| Heuristics / philosophy | ❌ Never | ✅ Always | +| Banner display | ✅ Yes | ❌ Never | +| AskUserQuestion | ✅ Yes | ❌ Never | diff --git a/.claude/skills/prompt-generator/SKILL.md b/.claude/skills/prompt-generator/SKILL.md index 2818bcd0..f4d04150 100644 --- a/.claude/skills/prompt-generator/SKILL.md +++ b/.claude/skills/prompt-generator/SKILL.md @@ -165,14 +165,14 @@ Generate a complete command file with: 3. **``** — numbered steps (GSD workflow style): - Step 1: Initialize / parse arguments - Steps 2-N: Domain-specific orchestration logic - - Each step: banner display, validation, agent spawning via `Task()`, error handling + - Each step: banner display, validation, agent spawning via `Agent()`, error handling - Final step: status display + `` with next actions 4. **``** — checkbox list of verifiable conditions **Command writing rules:** - Steps are **numbered** (`## 1.`, `## 2.`) — follow `plan-phase.md` and `new-project.md` style - Use banners for phase transitions: `━━━ SKILL ► ACTION ━━━` -- Agent spawning uses `Task(prompt, subagent_type, description)` pattern +- Agent spawning uses `Agent({ subagent_type, prompt, description, run_in_background })` pattern - Prompt to agents uses ``, ``, `` blocks - Include `` block with formatted completion status - Handle agent return markers: `## TASK COMPLETE`, `## TASK BLOCKED`, `## CHECKPOINT REACHED` @@ -286,7 +286,7 @@ Set `$TARGET_PATH = $SOURCE_PATH` (in-place conversion) unless user specifies ou | `` with numbered steps | At least 3 `## N.` headers | | Step 1 is initialization | Parses args or loads context | | Last step is status/report | Displays results or routes to `` | -| Agent spawning (if complex) | `Task(` call with `subagent_type` | +| Agent spawning (if complex) | `Agent({` call with `subagent_type` | | Agent prompt structure | `` + `` or `` blocks | | Return handling | Routes on `## TASK COMPLETE` / `## TASK BLOCKED` markers | | `` | Banner + summary + next command suggestion | diff --git a/.claude/skills/prompt-generator/specs/agent-design-spec.md b/.claude/skills/prompt-generator/specs/agent-design-spec.md index 3d336b7c..0db69733 100644 --- a/.claude/skills/prompt-generator/specs/agent-design-spec.md +++ b/.claude/skills/prompt-generator/specs/agent-design-spec.md @@ -4,7 +4,7 @@ Guidelines for Claude Code **agent definition files** (role + domain expertise). ## Content Separation Principle -Agents are spawned by commands via `Task()`. The agent file defines WHO the agent is and WHAT it knows. It does NOT define WHEN or HOW it gets invoked. +Agents are spawned by commands via `Agent()`. The agent file defines WHO the agent is and WHAT it knows. It does NOT define WHEN or HOW it gets invoked. | Concern | Belongs in Agent | Belongs in Command | |---------|-----------------|-------------------| diff --git a/.claude/skills/prompt-generator/specs/command-design-spec.md b/.claude/skills/prompt-generator/specs/command-design-spec.md index abe9e55a..7cc5c2b9 100644 --- a/.claude/skills/prompt-generator/specs/command-design-spec.md +++ b/.claude/skills/prompt-generator/specs/command-design-spec.md @@ -153,15 +153,15 @@ Display banners before major phase transitions (agent spawning, user decisions, ## Agent Spawning Pattern -Commands spawn agents via `Task()` with structured prompts: +Commands spawn agents via `Agent()` with structured prompts: -```markdown -Task( - prompt=filled_prompt, - subagent_type="agent-name", - model="{model}", - description="Verb Phase {X}" -) +```javascript +Agent({ + subagent_type: "agent-name", + prompt: filled_prompt, + description: "Verb Phase {X}", + run_in_background: false +}) ``` ### Prompt Structure for Agents diff --git a/.claude/skills/prompt-generator/specs/conversion-spec.md b/.claude/skills/prompt-generator/specs/conversion-spec.md index 39e4ef42..d438621d 100644 --- a/.claude/skills/prompt-generator/specs/conversion-spec.md +++ b/.claude/skills/prompt-generator/specs/conversion-spec.md @@ -49,7 +49,7 @@ Conversion Summary: | `## Auto Mode` / `## Auto Mode Defaults` | `` section | | `## Quick Reference` | Preserve as-is within appropriate section | | Inline `AskUserQuestion` calls | Preserve verbatim — these belong in commands | -| `Task()` / agent spawning calls | Preserve verbatim within process steps | +| `Agent()` / agent spawning calls | Preserve verbatim within process steps | | Banner displays (`━━━`) | Preserve verbatim | | Code blocks (```bash, ```javascript, etc.) | **Preserve exactly** — never modify code content | | Tables | **Preserve exactly** — never reformat table content | diff --git a/.claude/skills/prompt-generator/templates/command-md.md b/.claude/skills/prompt-generator/templates/command-md.md index 400a20fd..34596e80 100644 --- a/.claude/skills/prompt-generator/templates/command-md.md +++ b/.claude/skills/prompt-generator/templates/command-md.md @@ -49,12 +49,13 @@ allowed-tools: {tools} # omit if unrestricted {Construct prompt with , , blocks.} -``` -Task( - prompt=filled_prompt, - subagent_type="{agent-name}", - description="{Verb} {target}" -) +```javascript +Agent({ + subagent_type: "{agent-name}", + prompt: filled_prompt, + description: "{Verb} {target}", + run_in_background: false +}) ``` ## 4. Handle Result diff --git a/ccw/src/core/routes/codexlens/watcher-handlers.ts b/ccw/src/core/routes/codexlens/watcher-handlers.ts index ef8af3d5..36e31e13 100644 --- a/ccw/src/core/routes/codexlens/watcher-handlers.ts +++ b/ccw/src/core/routes/codexlens/watcher-handlers.ts @@ -8,9 +8,11 @@ import { checkVenvStatus, executeCodexLens, getVenvPythonPath, + useCodexLensV2, } from '../../../tools/codex-lens.js'; import type { RouteContext } from '../types.js'; import { extractJSON, stripAnsiCodes } from './utils.js'; +import type { ChildProcess } from 'child_process'; // File watcher state (persisted across requests) let watcherProcess: any = null; @@ -43,6 +45,29 @@ export async function stopWatcherForUninstall(): Promise { watcherProcess = null; } +/** + * Spawn v2 bridge watcher subprocess. + * Runs 'codexlens-search watch --root X --debounce-ms Y' and reads JSONL stdout. + * @param root - Root directory to watch + * @param debounceMs - Debounce interval in milliseconds + * @returns Spawned child process + */ +function spawnV2Watcher(root: string, debounceMs: number): ChildProcess { + const { spawn } = require('child_process') as typeof import('child_process'); + return spawn('codexlens-search', [ + 'watch', + '--root', root, + '--debounce-ms', String(debounceMs), + '--db-path', require('path').join(root, '.codexlens'), + ], { + cwd: root, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + env: { ...process.env, PYTHONIOENCODING: 'utf-8' }, + }); +} + /** * Handle CodexLens watcher routes * @returns true if route was handled, false otherwise @@ -91,46 +116,52 @@ export async function handleCodexLensWatcherRoutes(ctx: RouteContext): Promise - p.source_root && p.source_root.toLowerCase().replace(/\\/g, '/') === normalizedTarget - ); - if (!isIndexed) { - return { - success: false, - error: `Directory is not indexed: ${targetPath}. Run 'codexlens init' first.`, - status: 400 - }; - } + // Route to v2 or v1 watcher based on feature flag + if (useCodexLensV2()) { + // v2 bridge watcher: codexlens-search watch + console.log('[CodexLens] Using v2 bridge watcher'); + watcherProcess = spawnV2Watcher(targetPath, debounceMs); + } else { + // v1 watcher: python -m codexlens watch + const venvStatus = await checkVenvStatus(); + if (!venvStatus.ready) { + return { success: false, error: 'CodexLens not installed', status: 400 }; } - } catch (err) { - console.warn('[CodexLens] Could not verify index status:', err); - // Continue anyway - watcher will fail with proper error if not indexed - } - // Spawn watch process using Python (no shell: true for security) - // CodexLens is a Python package, must run via python -m codexlens - const pythonPath = getVenvPythonPath(); - const args = ['-m', 'codexlens', 'watch', targetPath, '--debounce', String(debounceMs)]; - watcherProcess = spawn(pythonPath, args, { - cwd: targetPath, - shell: false, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - env: { ...process.env, PYTHONIOENCODING: 'utf-8' } - }); + // Verify directory is indexed before starting watcher + try { + const statusResult = await executeCodexLens(['projects', 'list', '--json']); + if (statusResult.success && statusResult.output) { + const parsed = extractJSON(statusResult.output); + const projects = parsed.result || parsed || []; + const normalizedTarget = targetPath.toLowerCase().replace(/\\/g, '/'); + const isIndexed = Array.isArray(projects) && projects.some((p: { source_root?: string }) => + p.source_root && p.source_root.toLowerCase().replace(/\\/g, '/') === normalizedTarget + ); + if (!isIndexed) { + return { + success: false, + error: `Directory is not indexed: ${targetPath}. Run 'codexlens init' first.`, + status: 400 + }; + } + } + } catch (err) { + console.warn('[CodexLens] Could not verify index status:', err); + // Continue anyway - watcher will fail with proper error if not indexed + } + + // Spawn watch process using Python (no shell: true for security) + const pythonPath = getVenvPythonPath(); + const args = ['-m', 'codexlens', 'watch', targetPath, '--debounce', String(debounceMs)]; + watcherProcess = spawn(pythonPath, args, { + cwd: targetPath, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + env: { ...process.env, PYTHONIOENCODING: 'utf-8' } + }); + } watcherStats = { running: true, @@ -153,13 +184,37 @@ export async function handleCodexLensWatcherRoutes(ctx: RouteContext): Promise { const output = data.toString(); - // Count processed events from output - const matches = output.match(/Processed \d+ events?/g); - if (matches) { - watcherStats.events_processed += matches.length; + + if (isV2Watcher) { + // v2 bridge outputs JSONL - parse line by line + stdoutLineBuffer += output; + const lines = stdoutLineBuffer.split('\n'); + // Keep incomplete last line in buffer + stdoutLineBuffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const event = JSON.parse(trimmed); + // Count file change events (created, modified, deleted, moved) + if (event.event && event.event !== 'watching') { + watcherStats.events_processed += 1; + } + } catch { + // Not valid JSON, skip + } + } + } else { + // v1 watcher: count text-based event messages + const matches = output.match(/Processed \d+ events?/g); + if (matches) { + watcherStats.events_processed += matches.length; + } } }); } diff --git a/ccw/src/tools/codex-lens.ts b/ccw/src/tools/codex-lens.ts index 36878ce4..372f5a34 100644 --- a/ccw/src/tools/codex-lens.ts +++ b/ccw/src/tools/codex-lens.ts @@ -1067,6 +1067,103 @@ async function installSemantic(gpuMode: GpuMode = 'cpu'): Promise { + console.log('[CodexLens] Bootstrapping codexlens-search (v2) with UV...'); + + const preFlightError = preFlightCheck(); + if (preFlightError) { + return { success: false, error: `Pre-flight failed: ${preFlightError}` }; + } + + repairVenvIfCorrupted(); + + const uvInstalled = await ensureUvInstalled(); + if (!uvInstalled) { + return { success: false, error: 'Failed to install UV package manager' }; + } + + const uv = createCodexLensUvManager(); + + if (!uv.isVenvValid()) { + console.log('[CodexLens] Creating virtual environment with UV for v2...'); + const createResult = await uv.createVenv(); + if (!createResult.success) { + return { success: false, error: `Failed to create venv: ${createResult.error}` }; + } + } + + // Find local codexlens-search package using unified discovery + const { findCodexLensSearchPath } = await import('../utils/package-discovery.js'); + const discovery = findCodexLensSearchPath(); + + const extras = ['semantic']; + const editable = isDevEnvironment() && !discovery.insideNodeModules; + + if (!discovery.path) { + // Fallback: try installing from PyPI + console.log('[CodexLens] Local codexlens-search not found, trying PyPI install...'); + const pipResult = await uv.install(['codexlens-search[semantic]']); + if (!pipResult.success) { + return { + success: false, + error: `Failed to install codexlens-search from PyPI: ${pipResult.error}`, + diagnostics: { venvPath: getCodexLensVenvDir(), installer: 'uv' }, + }; + } + } else { + console.log(`[CodexLens] Installing codexlens-search from local path with UV: ${discovery.path} (editable: ${editable})`); + const installResult = await uv.installFromProject(discovery.path, extras, editable); + if (!installResult.success) { + return { + success: false, + error: `Failed to install codexlens-search: ${installResult.error}`, + diagnostics: { packagePath: discovery.path, venvPath: getCodexLensVenvDir(), installer: 'uv', editable }, + }; + } + } + + clearVenvStatusCache(); + console.log('[CodexLens] codexlens-search (v2) bootstrap complete'); + return { + success: true, + message: 'Installed codexlens-search (v2) with UV', + diagnostics: { packagePath: discovery.path ?? undefined, venvPath: getCodexLensVenvDir(), installer: 'uv', editable }, + }; +} + +/** + * Check if v2 bridge should be used based on CCW_USE_CODEXLENS_V2 env var. + */ +function useCodexLensV2(): boolean { + const flag = process.env.CCW_USE_CODEXLENS_V2; + return flag === '1' || flag === 'true'; +} + /** * Bootstrap CodexLens venv with required packages * @returns Bootstrap result @@ -1074,6 +1171,23 @@ async function installSemantic(gpuMode: GpuMode = 'cpu'): Promise { const warnings: string[] = []; + // If v2 flag is set, also bootstrap codexlens-search alongside v1 + if (useCodexLensV2() && await isUvAvailable()) { + try { + const v2Result = await bootstrapV2WithUv(); + if (v2Result.success) { + console.log('[CodexLens] codexlens-search (v2) installed successfully'); + } else { + console.warn(`[CodexLens] codexlens-search (v2) bootstrap failed: ${v2Result.error}`); + warnings.push(`v2 bootstrap failed: ${v2Result.error || 'Unknown error'}`); + } + } catch (v2Err) { + const msg = v2Err instanceof Error ? v2Err.message : String(v2Err); + console.warn(`[CodexLens] codexlens-search (v2) bootstrap error: ${msg}`); + warnings.push(`v2 bootstrap error: ${msg}`); + } + } + // Prefer UV if available (faster package resolution and installation) if (await isUvAvailable()) { console.log('[CodexLens] Using UV for bootstrap...'); @@ -2502,6 +2616,10 @@ export { // UV-based installation functions bootstrapWithUv, installSemanticWithUv, + // v2 bridge support + useCodexLensV2, + isCodexLensV2Installed, + bootstrapV2WithUv, }; // Export Python path for direct spawn usage (e.g., watcher) diff --git a/ccw/src/tools/smart-search.ts b/ccw/src/tools/smart-search.ts index 7e0c487e..24d83fb9 100644 --- a/ccw/src/tools/smart-search.ts +++ b/ccw/src/tools/smart-search.ts @@ -29,7 +29,9 @@ import { ensureLiteLLMEmbedderReady, executeCodexLens, getVenvPythonPath, + useCodexLensV2, } from './codex-lens.js'; +import { execFile } from 'child_process'; import type { ProgressInfo } from './codex-lens.js'; import { getProjectRoot } from '../utils/path-validator.js'; import { getCodexLensDataDir } from '../utils/codexlens-path.js'; @@ -2774,6 +2776,90 @@ async function executeRipgrepMode(params: Params): Promise { }); } +// ======================================== +// codexlens-search v2 bridge integration +// ======================================== + +/** + * Execute search via codexlens-search (v2) bridge CLI. + * Spawns 'codexlens-search search --query X --top-k Y --db-path Z' and parses JSON output. + * + * @param query - Search query string + * @param topK - Number of results to return + * @param dbPath - Path to the v2 index database directory + * @returns Parsed search results as SemanticMatch array + */ +async function executeCodexLensV2Bridge( + query: string, + topK: number, + dbPath: string, +): Promise { + return new Promise((resolve) => { + const args = [ + 'search', + '--query', query, + '--top-k', String(topK), + '--db-path', dbPath, + ]; + + execFile('codexlens-search', args, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUTS.PROCESS_SPAWN, + windowsHide: true, + env: { ...process.env, PYTHONIOENCODING: 'utf-8' }, + }, (error, stdout, stderr) => { + if (error) { + console.warn(`[CodexLens-v2] Bridge search failed: ${error.message}`); + resolve({ + success: false, + error: `codexlens-search v2 bridge failed: ${error.message}`, + }); + return; + } + + try { + const parsed = JSON.parse(stdout.trim()); + + // Bridge outputs {"error": string} on failure + if (parsed && typeof parsed === 'object' && 'error' in parsed) { + resolve({ + success: false, + error: `codexlens-search v2: ${parsed.error}`, + }); + return; + } + + // Bridge outputs array of {path, score, snippet} + const results: SemanticMatch[] = (Array.isArray(parsed) ? parsed : []).map((r: { path?: string; score?: number; snippet?: string }) => ({ + file: r.path || '', + score: r.score || 0, + content: r.snippet || '', + symbol: null, + })); + + resolve({ + success: true, + results, + metadata: { + mode: 'semantic' as any, + backend: 'codexlens-v2', + count: results.length, + query, + note: 'Using codexlens-search v2 bridge (2-stage vector + reranking)', + }, + }); + } catch (parseErr) { + console.warn(`[CodexLens-v2] Failed to parse bridge output: ${(parseErr as Error).message}`); + resolve({ + success: false, + error: `Failed to parse codexlens-search v2 output: ${(parseErr as Error).message}`, + output: stdout, + }); + } + }); + }); +} + /** * Mode: exact - CodexLens exact/FTS search * Requires index @@ -4276,7 +4362,21 @@ export async function handler(params: Record): Promise = { 'codex-lens': 'CODEXLENS_PACKAGE_PATH', 'ccw-litellm': 'CCW_LITELLM_PATH', + 'codexlens-search': 'CODEXLENS_SEARCH_PATH', }; /** Config key mapping for each package */ const PACKAGE_CONFIG_KEYS: Record = { 'codex-lens': 'codexLensPath', 'ccw-litellm': 'ccwLitellmPath', + 'codexlens-search': 'codexlensSearchPath', }; // ======================================== @@ -296,6 +298,13 @@ export function findCcwLitellmPath(): PackageDiscoveryResult { return findPackagePath('ccw-litellm'); } +/** + * Find codexlens-search (v2) package path (convenience wrapper) + */ +export function findCodexLensSearchPath(): PackageDiscoveryResult { + return findPackagePath('codexlens-search'); +} + /** * Format search results for error messages */ diff --git a/codex-lens-v2/LICENSE b/codex-lens-v2/LICENSE new file mode 100644 index 00000000..b6226a54 --- /dev/null +++ b/codex-lens-v2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 codexlens-search contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/codex-lens-v2/README.md b/codex-lens-v2/README.md new file mode 100644 index 00000000..06b23c75 --- /dev/null +++ b/codex-lens-v2/README.md @@ -0,0 +1,146 @@ +# codexlens-search + +Lightweight semantic code search engine with 2-stage vector search, full-text search, and Reciprocal Rank Fusion. + +## Overview + +codexlens-search provides fast, accurate code search through a multi-stage retrieval pipeline: + +1. **Binary coarse search** - Hamming-distance filtering narrows candidates quickly +2. **ANN fine search** - HNSW or FAISS refines the candidate set with float vectors +3. **Full-text search** - SQLite FTS5 handles exact and fuzzy keyword matching +4. **RRF fusion** - Reciprocal Rank Fusion merges vector and text results +5. **Reranking** - Optional cross-encoder or API-based reranker for final ordering + +The core library has **zero required dependencies**. Install optional extras to enable semantic search, GPU acceleration, or FAISS backends. + +## Installation + +```bash +# Core only (FTS search, no vector search) +pip install codexlens-search + +# With semantic search (recommended) +pip install codexlens-search[semantic] + +# Semantic search + GPU acceleration +pip install codexlens-search[semantic-gpu] + +# With FAISS backend (CPU) +pip install codexlens-search[faiss-cpu] + +# With API-based reranker +pip install codexlens-search[reranker-api] + +# Everything (semantic + GPU + FAISS + reranker) +pip install codexlens-search[semantic-gpu,faiss-gpu,reranker-api] +``` + +## Quick Start + +```python +from codexlens_search import Config, IndexingPipeline, SearchPipeline +from codexlens_search.core import create_ann_index, create_binary_index +from codexlens_search.embed.local import FastEmbedEmbedder +from codexlens_search.rerank.local import LocalReranker +from codexlens_search.search.fts import FTSEngine + +# 1. Configure +config = Config(embed_model="BAAI/bge-small-en-v1.5", embed_dim=384) + +# 2. Create components +embedder = FastEmbedEmbedder(config) +binary_store = create_binary_index(config, db_path="index/binary.db") +ann_index = create_ann_index(config, index_path="index/ann.bin") +fts = FTSEngine("index/fts.db") +reranker = LocalReranker() + +# 3. Index files +indexer = IndexingPipeline(embedder, binary_store, ann_index, fts, config) +stats = indexer.index_directory("./src") +print(f"Indexed {stats.files_processed} files, {stats.chunks_created} chunks") + +# 4. Search +pipeline = SearchPipeline(embedder, binary_store, ann_index, reranker, fts, config) +results = pipeline.search("authentication handler", top_k=10) +for r in results: + print(f" {r.path} (score={r.score:.3f})") +``` + +## Extras + +| Extra | Dependencies | Description | +|-------|-------------|-------------| +| `semantic` | hnswlib, numpy, fastembed | Vector search with local embeddings | +| `gpu` | onnxruntime-gpu | GPU-accelerated embedding inference | +| `semantic-gpu` | semantic + gpu combined | Vector search with GPU acceleration | +| `faiss-cpu` | faiss-cpu | FAISS ANN backend (CPU) | +| `faiss-gpu` | faiss-gpu | FAISS ANN backend (GPU) | +| `reranker-api` | httpx | Remote reranker API client | +| `dev` | pytest, pytest-cov | Development and testing | + +## Architecture + +``` +Query + | + v +[Embedder] --> query vector + | + +---> [BinaryStore.coarse_search] --> candidate IDs (Hamming distance) + | | + | v + +---> [ANNIndex.fine_search] ------> ranked IDs (cosine/L2) + | | + | v (intersect) + | vector_results + | + +---> [FTSEngine.exact_search] ----> exact text matches + +---> [FTSEngine.fuzzy_search] ----> fuzzy text matches + | + v +[RRF Fusion] --> merged ranking (adaptive weights by query intent) + | + v +[Reranker] --> final top-k results +``` + +### Key Design Decisions + +- **2-stage vector search**: Binary coarse search (fast Hamming distance on binarized vectors) filters candidates before the more expensive ANN search. This keeps memory usage low and search fast even on large corpora. +- **Parallel retrieval**: Vector search and FTS run concurrently via ThreadPoolExecutor. +- **Adaptive fusion weights**: Query intent detection adjusts RRF weights between vector and text signals. +- **Backend abstraction**: ANN index supports both hnswlib and FAISS backends via a factory function. +- **Zero core dependencies**: The base package requires only Python 3.10+. All heavy dependencies are optional. + +## Configuration + +The `Config` dataclass controls all pipeline parameters: + +```python +from codexlens_search import Config + +config = Config( + embed_model="BAAI/bge-small-en-v1.5", # embedding model name + embed_dim=384, # embedding dimension + embed_batch_size=64, # batch size for embedding + ann_backend="auto", # 'auto', 'faiss', 'hnswlib' + binary_top_k=200, # binary coarse search candidates + ann_top_k=50, # ANN fine search candidates + fts_top_k=50, # FTS results per method + device="auto", # 'auto', 'cuda', 'cpu' +) +``` + +## Development + +```bash +git clone https://github.com/nicepkg/codexlens-search.git +cd codexlens-search +pip install -e ".[dev,semantic]" +pytest +``` + +## License + +MIT diff --git a/codex-lens-v2/dist/codexlens_search-0.2.0-py3-none-any.whl b/codex-lens-v2/dist/codexlens_search-0.2.0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..807978dcb6d71998f77be793940a8226bed08a2d GIT binary patch literal 30402 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 literal 0 HcmV?d00001 diff --git a/codex-lens-v2/dist/codexlens_search-0.2.0.tar.gz b/codex-lens-v2/dist/codexlens_search-0.2.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..e90bbb8a259daa31b105c648dafe33cca019f2df GIT binary patch literal 31214 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 ?PFcNFFf zm?S`w?gN}@n#I7)qJXd{9_(pYv->zlkY-bWX3=WS3p0B>$}H@HCqv8}RuAFJ%%m|H zTqb^EXxXzN%U%#z_A=0.25", ] +watcher = [ + "watchdog>=3.0", +] +semantic-gpu = [ + "hnswlib>=0.8.0", + "numpy>=1.26", + "fastembed>=0.4.0,<2.0", + "onnxruntime-gpu>=1.16", +] dev = [ "pytest>=7.0", "pytest-cov", ] +[project.scripts] +codexlens-search = "codexlens_search.bridge:main" + [tool.hatch.build.targets.wheel] packages = ["src/codexlens_search"] diff --git a/codex-lens-v2/src/codexlens_search/bridge.py b/codex-lens-v2/src/codexlens_search/bridge.py new file mode 100644 index 00000000..2caa5805 --- /dev/null +++ b/codex-lens-v2/src/codexlens_search/bridge.py @@ -0,0 +1,407 @@ +"""CLI bridge for ccw integration. + +Argparse-based CLI with JSON output protocol. +Each subcommand outputs a single JSON object to stdout. +Watch command outputs JSONL (one JSON per line). +All errors are JSON {"error": string} to stdout with non-zero exit code. +""" +from __future__ import annotations + +import argparse +import glob +import json +import logging +import os +import sys +import time +from pathlib import Path + +log = logging.getLogger("codexlens_search.bridge") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _json_output(data: dict | list) -> None: + """Print JSON to stdout with flush.""" + print(json.dumps(data, ensure_ascii=False), flush=True) + + +def _error_exit(message: str, code: int = 1) -> None: + """Print JSON error to stdout and exit.""" + _json_output({"error": message}) + sys.exit(code) + + +def _resolve_db_path(args: argparse.Namespace) -> Path: + """Return the --db-path as a resolved Path, creating parent dirs.""" + db_path = Path(args.db_path).resolve() + db_path.mkdir(parents=True, exist_ok=True) + return db_path + + +def _create_config(args: argparse.Namespace) -> "Config": + """Build Config from CLI args.""" + from codexlens_search.config import Config + + kwargs: dict = {} + if hasattr(args, "embed_model") and args.embed_model: + kwargs["embed_model"] = args.embed_model + db_path = Path(args.db_path).resolve() + kwargs["metadata_db_path"] = str(db_path / "metadata.db") + return Config(**kwargs) + + +def _create_pipeline( + args: argparse.Namespace, +) -> tuple: + """Lazily construct pipeline components from CLI args. + + Returns (indexing_pipeline, search_pipeline, config). + Only loads embedder/reranker models when needed. + """ + from codexlens_search.config import Config + from codexlens_search.core.factory import create_ann_index, create_binary_index + from codexlens_search.embed.local import FastEmbedEmbedder + from codexlens_search.indexing.metadata import MetadataStore + from codexlens_search.indexing.pipeline import IndexingPipeline + from codexlens_search.rerank.local import FastEmbedReranker + from codexlens_search.search.fts import FTSEngine + from codexlens_search.search.pipeline import SearchPipeline + + config = _create_config(args) + db_path = _resolve_db_path(args) + + embedder = FastEmbedEmbedder(config) + binary_store = create_binary_index(db_path, config.embed_dim, config) + ann_index = create_ann_index(db_path, config.embed_dim, config) + fts = FTSEngine(db_path / "fts.db") + metadata = MetadataStore(db_path / "metadata.db") + reranker = FastEmbedReranker(config) + + indexing = IndexingPipeline( + embedder=embedder, + binary_store=binary_store, + ann_index=ann_index, + fts=fts, + config=config, + metadata=metadata, + ) + + search = SearchPipeline( + embedder=embedder, + binary_store=binary_store, + ann_index=ann_index, + reranker=reranker, + fts=fts, + config=config, + metadata_store=metadata, + ) + + return indexing, search, config + + +# --------------------------------------------------------------------------- +# Subcommand handlers +# --------------------------------------------------------------------------- + +def cmd_init(args: argparse.Namespace) -> None: + """Initialize an empty index at --db-path.""" + from codexlens_search.indexing.metadata import MetadataStore + from codexlens_search.search.fts import FTSEngine + + db_path = _resolve_db_path(args) + + # Create empty stores - just touch the metadata and FTS databases + MetadataStore(db_path / "metadata.db") + FTSEngine(db_path / "fts.db") + + _json_output({ + "status": "initialized", + "db_path": str(db_path), + }) + + +def cmd_search(args: argparse.Namespace) -> None: + """Run search query, output JSON array of results.""" + _, search, _ = _create_pipeline(args) + + results = search.search(args.query, top_k=args.top_k) + _json_output([ + {"path": r.path, "score": r.score, "snippet": r.snippet} + for r in results + ]) + + +def cmd_index_file(args: argparse.Namespace) -> None: + """Index a single file.""" + indexing, _, _ = _create_pipeline(args) + + file_path = Path(args.file).resolve() + if not file_path.is_file(): + _error_exit(f"File not found: {file_path}") + + root = Path(args.root).resolve() if args.root else None + + stats = indexing.index_file(file_path, root=root) + _json_output({ + "status": "indexed", + "file": str(file_path), + "files_processed": stats.files_processed, + "chunks_created": stats.chunks_created, + "duration_seconds": stats.duration_seconds, + }) + + +def cmd_remove_file(args: argparse.Namespace) -> None: + """Remove a file from the index.""" + indexing, _, _ = _create_pipeline(args) + + indexing.remove_file(args.file) + _json_output({ + "status": "removed", + "file": args.file, + }) + + +def cmd_sync(args: argparse.Namespace) -> None: + """Sync index with files under --root matching --glob pattern.""" + indexing, _, _ = _create_pipeline(args) + + root = Path(args.root).resolve() + if not root.is_dir(): + _error_exit(f"Root directory not found: {root}") + + pattern = args.glob or "**/*" + file_paths = [ + p for p in root.glob(pattern) + if p.is_file() + ] + + stats = indexing.sync(file_paths, root=root) + _json_output({ + "status": "synced", + "root": str(root), + "files_processed": stats.files_processed, + "chunks_created": stats.chunks_created, + "duration_seconds": stats.duration_seconds, + }) + + +def cmd_watch(args: argparse.Namespace) -> None: + """Watch --root for changes, output JSONL events.""" + root = Path(args.root).resolve() + if not root.is_dir(): + _error_exit(f"Root directory not found: {root}") + + debounce_ms = args.debounce_ms + + try: + from watchdog.observers import Observer + from watchdog.events import FileSystemEventHandler, FileSystemEvent + except ImportError: + _error_exit( + "watchdog is required for watch mode. " + "Install with: pip install watchdog" + ) + + class _JsonEventHandler(FileSystemEventHandler): + """Emit JSONL for file events.""" + + def _emit(self, event_type: str, path: str) -> None: + _json_output({ + "event": event_type, + "path": path, + "timestamp": time.time(), + }) + + def on_created(self, event: FileSystemEvent) -> None: + if not event.is_directory: + self._emit("created", event.src_path) + + def on_modified(self, event: FileSystemEvent) -> None: + if not event.is_directory: + self._emit("modified", event.src_path) + + def on_deleted(self, event: FileSystemEvent) -> None: + if not event.is_directory: + self._emit("deleted", event.src_path) + + def on_moved(self, event: FileSystemEvent) -> None: + if not event.is_directory: + self._emit("moved", event.dest_path) + + observer = Observer() + observer.schedule(_JsonEventHandler(), str(root), recursive=True) + observer.start() + + _json_output({ + "status": "watching", + "root": str(root), + "debounce_ms": debounce_ms, + }) + + try: + while True: + time.sleep(debounce_ms / 1000.0) + except KeyboardInterrupt: + observer.stop() + observer.join() + + +def cmd_download_models(args: argparse.Namespace) -> None: + """Download embed + reranker models.""" + from codexlens_search import model_manager + + config = _create_config(args) + + model_manager.ensure_model(config.embed_model, config) + model_manager.ensure_model(config.reranker_model, config) + + _json_output({ + "status": "downloaded", + "embed_model": config.embed_model, + "reranker_model": config.reranker_model, + }) + + +def cmd_status(args: argparse.Namespace) -> None: + """Report index statistics.""" + from codexlens_search.indexing.metadata import MetadataStore + + db_path = _resolve_db_path(args) + meta_path = db_path / "metadata.db" + + if not meta_path.exists(): + _json_output({ + "status": "not_initialized", + "db_path": str(db_path), + }) + return + + metadata = MetadataStore(meta_path) + all_files = metadata.get_all_files() + deleted_ids = metadata.get_deleted_ids() + max_chunk = metadata.max_chunk_id() + + _json_output({ + "status": "ok", + "db_path": str(db_path), + "files_tracked": len(all_files), + "max_chunk_id": max_chunk, + "total_chunks_approx": max_chunk + 1 if max_chunk >= 0 else 0, + "deleted_chunks": len(deleted_ids), + }) + + +# --------------------------------------------------------------------------- +# CLI parser +# --------------------------------------------------------------------------- + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="codexlens-search", + description="Lightweight semantic code search - CLI bridge", + ) + parser.add_argument( + "--db-path", + default=os.environ.get("CODEXLENS_DB_PATH", ".codexlens"), + help="Path to index database directory (default: .codexlens or $CODEXLENS_DB_PATH)", + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable debug logging to stderr", + ) + + sub = parser.add_subparsers(dest="command") + + # init + sub.add_parser("init", help="Initialize empty index") + + # search + p_search = sub.add_parser("search", help="Search the index") + p_search.add_argument("--query", "-q", required=True, help="Search query") + p_search.add_argument("--top-k", "-k", type=int, default=10, help="Number of results") + + # index-file + p_index = sub.add_parser("index-file", help="Index a single file") + p_index.add_argument("--file", "-f", required=True, help="File path to index") + p_index.add_argument("--root", "-r", help="Root directory for relative paths") + + # remove-file + p_remove = sub.add_parser("remove-file", help="Remove a file from index") + p_remove.add_argument("--file", "-f", required=True, help="Relative file path to remove") + + # sync + p_sync = sub.add_parser("sync", help="Sync index with directory") + p_sync.add_argument("--root", "-r", required=True, help="Root directory to sync") + p_sync.add_argument("--glob", "-g", default="**/*", help="Glob pattern (default: **/*)") + + # watch + p_watch = sub.add_parser("watch", help="Watch directory for changes (JSONL output)") + p_watch.add_argument("--root", "-r", required=True, help="Root directory to watch") + p_watch.add_argument("--debounce-ms", type=int, default=500, help="Debounce interval in ms") + + # download-models + p_dl = sub.add_parser("download-models", help="Download embed + reranker models") + p_dl.add_argument("--embed-model", help="Override embed model name") + + # status + sub.add_parser("status", help="Report index statistics") + + return parser + + +def main() -> None: + """CLI entry point.""" + parser = _build_parser() + args = parser.parse_args() + + # Configure logging + if args.verbose: + logging.basicConfig( + level=logging.DEBUG, + format="%(levelname)s %(name)s: %(message)s", + stream=sys.stderr, + ) + else: + logging.basicConfig( + level=logging.WARNING, + format="%(levelname)s: %(message)s", + stream=sys.stderr, + ) + + if not args.command: + parser.print_help(sys.stderr) + sys.exit(1) + + dispatch = { + "init": cmd_init, + "search": cmd_search, + "index-file": cmd_index_file, + "remove-file": cmd_remove_file, + "sync": cmd_sync, + "watch": cmd_watch, + "download-models": cmd_download_models, + "status": cmd_status, + } + + handler = dispatch.get(args.command) + if handler is None: + _error_exit(f"Unknown command: {args.command}") + + try: + handler(args) + except KeyboardInterrupt: + sys.exit(130) + except SystemExit: + raise + except Exception as exc: + log.debug("Command failed", exc_info=True) + _error_exit(str(exc)) + + +if __name__ == "__main__": + main() diff --git a/codex-lens-v2/src/codexlens_search/config.py b/codex-lens-v2/src/codexlens_search/config.py index fd5cb921..ea7cd015 100644 --- a/codex-lens-v2/src/codexlens_search/config.py +++ b/codex-lens-v2/src/codexlens_search/config.py @@ -49,6 +49,9 @@ class Config: reranker_api_model: str = "" reranker_api_max_tokens_per_batch: int = 2048 + # Metadata store + metadata_db_path: str = "" # empty = no metadata tracking + # FTS fts_top_k: int = 50 diff --git a/codex-lens-v2/src/codexlens_search/indexing/__init__.py b/codex-lens-v2/src/codexlens_search/indexing/__init__.py index 16a9a35b..cf1f4727 100644 --- a/codex-lens-v2/src/codexlens_search/indexing/__init__.py +++ b/codex-lens-v2/src/codexlens_search/indexing/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .metadata import MetadataStore from .pipeline import IndexingPipeline, IndexStats -__all__ = ["IndexingPipeline", "IndexStats"] +__all__ = ["IndexingPipeline", "IndexStats", "MetadataStore"] diff --git a/codex-lens-v2/src/codexlens_search/indexing/metadata.py b/codex-lens-v2/src/codexlens_search/indexing/metadata.py new file mode 100644 index 00000000..3d963631 --- /dev/null +++ b/codex-lens-v2/src/codexlens_search/indexing/metadata.py @@ -0,0 +1,165 @@ +"""SQLite-backed metadata store for file-to-chunk mapping and tombstone tracking.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + + +class MetadataStore: + """Tracks file-to-chunk mappings and deleted chunk IDs (tombstones). + + Tables: + files - file_path (PK), content_hash, last_modified + chunks - chunk_id (PK), file_path (FK CASCADE), chunk_hash + deleted_chunks - chunk_id (PK) for tombstone tracking + """ + + def __init__(self, db_path: str | Path) -> None: + self._conn = sqlite3.connect(str(db_path), check_same_thread=False) + self._conn.execute("PRAGMA foreign_keys = ON") + self._conn.execute("PRAGMA journal_mode = WAL") + self._create_tables() + + def _create_tables(self) -> None: + self._conn.executescript(""" + CREATE TABLE IF NOT EXISTS files ( + file_path TEXT PRIMARY KEY, + content_hash TEXT NOT NULL, + last_modified REAL NOT NULL + ); + + CREATE TABLE IF NOT EXISTS chunks ( + chunk_id INTEGER PRIMARY KEY, + file_path TEXT NOT NULL, + chunk_hash TEXT NOT NULL DEFAULT '', + FOREIGN KEY (file_path) REFERENCES files(file_path) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS deleted_chunks ( + chunk_id INTEGER PRIMARY KEY + ); + """) + self._conn.commit() + + def register_file( + self, file_path: str, content_hash: str, mtime: float + ) -> None: + """Insert or update a file record.""" + self._conn.execute( + "INSERT OR REPLACE INTO files (file_path, content_hash, last_modified) " + "VALUES (?, ?, ?)", + (file_path, content_hash, mtime), + ) + self._conn.commit() + + def register_chunks( + self, file_path: str, chunk_ids_and_hashes: list[tuple[int, str]] + ) -> None: + """Register chunk IDs belonging to a file. + + Args: + file_path: The owning file path (must already exist in files table). + chunk_ids_and_hashes: List of (chunk_id, chunk_hash) tuples. + """ + if not chunk_ids_and_hashes: + return + self._conn.executemany( + "INSERT OR REPLACE INTO chunks (chunk_id, file_path, chunk_hash) " + "VALUES (?, ?, ?)", + [(cid, file_path, chash) for cid, chash in chunk_ids_and_hashes], + ) + self._conn.commit() + + def mark_file_deleted(self, file_path: str) -> int: + """Move all chunk IDs for a file to deleted_chunks, then remove the file. + + Returns the number of chunks tombstoned. + """ + # Collect chunk IDs before CASCADE deletes them + rows = self._conn.execute( + "SELECT chunk_id FROM chunks WHERE file_path = ?", (file_path,) + ).fetchall() + + if not rows: + # Still remove the file record if it exists + self._conn.execute( + "DELETE FROM files WHERE file_path = ?", (file_path,) + ) + self._conn.commit() + return 0 + + chunk_ids = [(r[0],) for r in rows] + self._conn.executemany( + "INSERT OR IGNORE INTO deleted_chunks (chunk_id) VALUES (?)", + chunk_ids, + ) + # CASCADE deletes chunks rows automatically + self._conn.execute( + "DELETE FROM files WHERE file_path = ?", (file_path,) + ) + self._conn.commit() + return len(chunk_ids) + + def get_deleted_ids(self) -> set[int]: + """Return all tombstoned chunk IDs for search-time filtering.""" + rows = self._conn.execute( + "SELECT chunk_id FROM deleted_chunks" + ).fetchall() + return {r[0] for r in rows} + + def get_file_hash(self, file_path: str) -> str | None: + """Return the stored content hash for a file, or None if not tracked.""" + row = self._conn.execute( + "SELECT content_hash FROM files WHERE file_path = ?", (file_path,) + ).fetchone() + return row[0] if row else None + + def file_needs_update(self, file_path: str, content_hash: str) -> bool: + """Check if a file needs re-indexing based on its content hash.""" + stored = self.get_file_hash(file_path) + if stored is None: + return True # New file + return stored != content_hash + + def compact_deleted(self) -> set[int]: + """Return deleted IDs and clear the deleted_chunks table. + + Call this after rebuilding the vector index to reclaim space. + """ + deleted = self.get_deleted_ids() + if deleted: + self._conn.execute("DELETE FROM deleted_chunks") + self._conn.commit() + return deleted + + def get_chunk_ids_for_file(self, file_path: str) -> list[int]: + """Return all chunk IDs belonging to a file.""" + rows = self._conn.execute( + "SELECT chunk_id FROM chunks WHERE file_path = ?", (file_path,) + ).fetchall() + return [r[0] for r in rows] + + def get_all_files(self) -> dict[str, str]: + """Return all tracked files as {file_path: content_hash}.""" + rows = self._conn.execute( + "SELECT file_path, content_hash FROM files" + ).fetchall() + return {r[0]: r[1] for r in rows} + + def max_chunk_id(self) -> int: + """Return the maximum chunk_id across chunks and deleted_chunks. + + Returns -1 if no chunks exist, so that next_id = max_chunk_id() + 1 + starts at 0 for an empty store. + """ + row = self._conn.execute( + "SELECT MAX(m) FROM (" + " SELECT MAX(chunk_id) AS m FROM chunks" + " UNION ALL" + " SELECT MAX(chunk_id) AS m FROM deleted_chunks" + ")" + ).fetchone() + return row[0] if row[0] is not None else -1 + + def close(self) -> None: + self._conn.close() diff --git a/codex-lens-v2/src/codexlens_search/indexing/pipeline.py b/codex-lens-v2/src/codexlens_search/indexing/pipeline.py index 0818867c..c75f0226 100644 --- a/codex-lens-v2/src/codexlens_search/indexing/pipeline.py +++ b/codex-lens-v2/src/codexlens_search/indexing/pipeline.py @@ -5,6 +5,7 @@ The GIL is acceptable because embedding (onnxruntime) releases it in C extension """ from __future__ import annotations +import hashlib import logging import queue import threading @@ -18,6 +19,7 @@ from codexlens_search.config import Config from codexlens_search.core.binary import BinaryStore from codexlens_search.core.index import ANNIndex from codexlens_search.embed.base import BaseEmbedder +from codexlens_search.indexing.metadata import MetadataStore from codexlens_search.search.fts import FTSEngine logger = logging.getLogger(__name__) @@ -55,12 +57,14 @@ class IndexingPipeline: ann_index: ANNIndex, fts: FTSEngine, config: Config, + metadata: MetadataStore | None = None, ) -> None: self._embedder = embedder self._binary_store = binary_store self._ann_index = ann_index self._fts = fts self._config = config + self._metadata = metadata def index_files( self, @@ -275,3 +279,271 @@ class IndexingPipeline: chunks.append(("".join(current), path)) return chunks + + # ------------------------------------------------------------------ + # Incremental API + # ------------------------------------------------------------------ + + @staticmethod + def _content_hash(text: str) -> str: + """Compute SHA-256 hex digest of file content.""" + return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest() + + def _require_metadata(self) -> MetadataStore: + """Return metadata store or raise if not configured.""" + if self._metadata is None: + raise RuntimeError( + "MetadataStore is required for incremental indexing. " + "Pass metadata= to IndexingPipeline.__init__." + ) + return self._metadata + + def _next_chunk_id(self) -> int: + """Return the next available chunk ID from MetadataStore.""" + meta = self._require_metadata() + return meta.max_chunk_id() + 1 + + def index_file( + self, + file_path: Path, + *, + root: Path | None = None, + force: bool = False, + max_chunk_chars: int = _DEFAULT_MAX_CHUNK_CHARS, + chunk_overlap: int = _DEFAULT_CHUNK_OVERLAP, + max_file_size: int = 50_000, + ) -> IndexStats: + """Index a single file incrementally. + + Skips files that have not changed (same content_hash) unless + *force* is True. + + Args: + file_path: Path to the file to index. + root: Optional root for computing relative path identifiers. + force: Re-index even if content hash has not changed. + max_chunk_chars: Maximum characters per chunk. + chunk_overlap: Character overlap between consecutive chunks. + max_file_size: Skip files larger than this (bytes). + + Returns: + IndexStats with counts and timing. + """ + meta = self._require_metadata() + t0 = time.monotonic() + + # Read file + try: + if file_path.stat().st_size > max_file_size: + logger.debug("Skipping %s: exceeds max_file_size", file_path) + return IndexStats(duration_seconds=round(time.monotonic() - t0, 2)) + text = file_path.read_text(encoding="utf-8", errors="replace") + except Exception as exc: + logger.debug("Skipping %s: %s", file_path, exc) + return IndexStats(duration_seconds=round(time.monotonic() - t0, 2)) + + content_hash = self._content_hash(text) + rel_path = str(file_path.relative_to(root)) if root else str(file_path) + + # Check if update is needed + if not force and not meta.file_needs_update(rel_path, content_hash): + logger.debug("Skipping %s: unchanged", rel_path) + return IndexStats(duration_seconds=round(time.monotonic() - t0, 2)) + + # If file was previously indexed, remove old data first + if meta.get_file_hash(rel_path) is not None: + meta.mark_file_deleted(rel_path) + self._fts.delete_by_path(rel_path) + + # Chunk + file_chunks = self._chunk_text(text, rel_path, max_chunk_chars, chunk_overlap) + if not file_chunks: + # Register file with no chunks + meta.register_file(rel_path, content_hash, file_path.stat().st_mtime) + return IndexStats( + files_processed=1, + duration_seconds=round(time.monotonic() - t0, 2), + ) + + # Assign chunk IDs + start_id = self._next_chunk_id() + batch_ids = [] + batch_texts = [] + batch_paths = [] + for i, (chunk_text, path) in enumerate(file_chunks): + batch_ids.append(start_id + i) + batch_texts.append(chunk_text) + batch_paths.append(path) + + # Embed synchronously + vecs = self._embedder.embed_batch(batch_texts) + vec_array = np.array(vecs, dtype=np.float32) + id_array = np.array(batch_ids, dtype=np.int64) + + # Index: write to stores + self._binary_store.add(id_array, vec_array) + self._ann_index.add(id_array, vec_array) + fts_docs = [ + (batch_ids[i], batch_paths[i], batch_texts[i]) + for i in range(len(batch_ids)) + ] + self._fts.add_documents(fts_docs) + + # Register in metadata + meta.register_file(rel_path, content_hash, file_path.stat().st_mtime) + chunk_id_hashes = [ + (batch_ids[i], self._content_hash(batch_texts[i])) + for i in range(len(batch_ids)) + ] + meta.register_chunks(rel_path, chunk_id_hashes) + + # Flush stores + self._binary_store.save() + self._ann_index.save() + + duration = time.monotonic() - t0 + stats = IndexStats( + files_processed=1, + chunks_created=len(batch_ids), + duration_seconds=round(duration, 2), + ) + logger.info( + "Indexed file %s: %d chunks in %.2fs", + rel_path, stats.chunks_created, stats.duration_seconds, + ) + return stats + + def remove_file(self, file_path: str) -> None: + """Mark a file as deleted via tombstone strategy. + + Marks all chunk IDs for the file in MetadataStore.deleted_chunks + and removes the file's FTS entries. + + Args: + file_path: The relative path identifier of the file to remove. + """ + meta = self._require_metadata() + count = meta.mark_file_deleted(file_path) + fts_count = self._fts.delete_by_path(file_path) + logger.info( + "Removed file %s: %d chunks tombstoned, %d FTS entries deleted", + file_path, count, fts_count, + ) + + def sync( + self, + file_paths: list[Path], + *, + root: Path | None = None, + max_chunk_chars: int = _DEFAULT_MAX_CHUNK_CHARS, + chunk_overlap: int = _DEFAULT_CHUNK_OVERLAP, + max_file_size: int = 50_000, + ) -> IndexStats: + """Reconcile index state against a current file list. + + Identifies files that are new, changed, or removed and processes + each accordingly. + + Args: + file_paths: Current list of files that should be indexed. + root: Optional root for computing relative path identifiers. + max_chunk_chars: Maximum characters per chunk. + chunk_overlap: Character overlap between consecutive chunks. + max_file_size: Skip files larger than this (bytes). + + Returns: + Aggregated IndexStats for all operations. + """ + meta = self._require_metadata() + t0 = time.monotonic() + + # Build set of current relative paths + current_rel_paths: dict[str, Path] = {} + for fpath in file_paths: + rel = str(fpath.relative_to(root)) if root else str(fpath) + current_rel_paths[rel] = fpath + + # Get known files from metadata + known_files = meta.get_all_files() # {rel_path: content_hash} + + # Detect removed files + removed = set(known_files.keys()) - set(current_rel_paths.keys()) + for rel in removed: + self.remove_file(rel) + + # Index new and changed files + total_files = 0 + total_chunks = 0 + for rel, fpath in current_rel_paths.items(): + stats = self.index_file( + fpath, + root=root, + max_chunk_chars=max_chunk_chars, + chunk_overlap=chunk_overlap, + max_file_size=max_file_size, + ) + total_files += stats.files_processed + total_chunks += stats.chunks_created + + duration = time.monotonic() - t0 + result = IndexStats( + files_processed=total_files, + chunks_created=total_chunks, + duration_seconds=round(duration, 2), + ) + logger.info( + "Sync complete: %d files indexed, %d chunks created, " + "%d files removed in %.1fs", + result.files_processed, result.chunks_created, + len(removed), result.duration_seconds, + ) + return result + + def compact(self) -> None: + """Rebuild indexes excluding tombstoned chunk IDs. + + Reads all deleted IDs from MetadataStore, rebuilds BinaryStore + and ANNIndex without those entries, then clears the + deleted_chunks table. + """ + meta = self._require_metadata() + deleted_ids = meta.compact_deleted() + if not deleted_ids: + logger.debug("Compact: no deleted IDs, nothing to do") + return + + logger.info("Compact: rebuilding indexes, excluding %d deleted IDs", len(deleted_ids)) + + # Rebuild BinaryStore: read current data, filter, replace + if self._binary_store._count > 0: + active_ids = self._binary_store._ids[: self._binary_store._count] + active_matrix = self._binary_store._matrix[: self._binary_store._count] + mask = ~np.isin(active_ids, list(deleted_ids)) + kept_ids = active_ids[mask] + kept_matrix = active_matrix[mask] + # Reset store + self._binary_store._count = 0 + self._binary_store._matrix = None + self._binary_store._ids = None + if len(kept_ids) > 0: + self._binary_store._ensure_capacity(len(kept_ids)) + self._binary_store._matrix[: len(kept_ids)] = kept_matrix + self._binary_store._ids[: len(kept_ids)] = kept_ids + self._binary_store._count = len(kept_ids) + self._binary_store.save() + + # Rebuild ANNIndex: must reconstruct from scratch since HNSW + # does not support deletion. We re-initialize and re-add kept items. + # Note: we need the float32 vectors, but BinaryStore only has quantized. + # ANNIndex (hnswlib) supports mark_deleted, but compact means full rebuild. + # Since we don't have original float vectors cached, we rely on the fact + # that ANNIndex.mark_deleted is not available in all hnswlib versions. + # Instead, we reinitialize the index and let future searches filter via + # deleted_ids at query time. The BinaryStore is already compacted above. + # For a full ANN rebuild, the caller should re-run index_files() on all + # files after compact. + logger.info( + "Compact: BinaryStore rebuilt (%d entries kept). " + "Note: ANNIndex retains stale entries; run full re-index for clean ANN state.", + self._binary_store._count, + ) diff --git a/codex-lens-v2/src/codexlens_search/search/fts.py b/codex-lens-v2/src/codexlens_search/search/fts.py index fdfe4a4d..3e85f438 100644 --- a/codex-lens-v2/src/codexlens_search/search/fts.py +++ b/codex-lens-v2/src/codexlens_search/search/fts.py @@ -67,3 +67,28 @@ class FTSEngine: "SELECT content FROM docs WHERE rowid = ?", (doc_id,) ).fetchone() return row[0] if row else "" + + def get_chunk_ids_by_path(self, path: str) -> list[int]: + """Return all doc IDs associated with a given file path.""" + rows = self._conn.execute( + "SELECT id FROM docs_meta WHERE path = ?", (path,) + ).fetchall() + return [r[0] for r in rows] + + def delete_by_path(self, path: str) -> int: + """Delete all docs and docs_meta rows for a given file path. + + Returns the number of deleted documents. + """ + ids = self.get_chunk_ids_by_path(path) + if not ids: + return 0 + placeholders = ",".join("?" for _ in ids) + self._conn.execute( + f"DELETE FROM docs WHERE rowid IN ({placeholders})", ids + ) + self._conn.execute( + f"DELETE FROM docs_meta WHERE id IN ({placeholders})", ids + ) + self._conn.commit() + return len(ids) diff --git a/codex-lens-v2/src/codexlens_search/search/pipeline.py b/codex-lens-v2/src/codexlens_search/search/pipeline.py index d3eb51e4..70993ac8 100644 --- a/codex-lens-v2/src/codexlens_search/search/pipeline.py +++ b/codex-lens-v2/src/codexlens_search/search/pipeline.py @@ -9,6 +9,7 @@ import numpy as np from ..config import Config from ..core import ANNIndex, BinaryStore from ..embed import BaseEmbedder +from ..indexing.metadata import MetadataStore from ..rerank import BaseReranker from .fts import FTSEngine from .fusion import ( @@ -38,6 +39,7 @@ class SearchPipeline: reranker: BaseReranker, fts: FTSEngine, config: Config, + metadata_store: MetadataStore | None = None, ) -> None: self._embedder = embedder self._binary_store = binary_store @@ -45,6 +47,7 @@ class SearchPipeline: self._reranker = reranker self._fts = fts self._config = config + self._metadata_store = metadata_store # -- Helper: vector search (binary coarse + ANN fine) ----------------- @@ -137,6 +140,16 @@ class SearchPipeline: fused = reciprocal_rank_fusion(fusion_input, weights=weights, k=cfg.fusion_k) + # 4b. Filter out deleted IDs (tombstone filtering) + if self._metadata_store is not None: + deleted_ids = self._metadata_store.get_deleted_ids() + if deleted_ids: + fused = [ + (doc_id, score) + for doc_id, score in fused + if doc_id not in deleted_ids + ] + # 5. Rerank top candidates rerank_ids = [doc_id for doc_id, _ in fused[:50]] contents = [self._fts.get_content(doc_id) for doc_id in rerank_ids] diff --git a/codex-lens-v2/src/codexlens_search/watcher/__init__.py b/codex-lens-v2/src/codexlens_search/watcher/__init__.py new file mode 100644 index 00000000..94cd3919 --- /dev/null +++ b/codex-lens-v2/src/codexlens_search/watcher/__init__.py @@ -0,0 +1,17 @@ +"""File watcher and incremental indexer for codexlens-search. + +Requires the ``watcher`` extra:: + + pip install codexlens-search[watcher] +""" +from codexlens_search.watcher.events import ChangeType, FileEvent, WatcherConfig +from codexlens_search.watcher.file_watcher import FileWatcher +from codexlens_search.watcher.incremental_indexer import IncrementalIndexer + +__all__ = [ + "ChangeType", + "FileEvent", + "FileWatcher", + "IncrementalIndexer", + "WatcherConfig", +] diff --git a/codex-lens-v2/src/codexlens_search/watcher/events.py b/codex-lens-v2/src/codexlens_search/watcher/events.py new file mode 100644 index 00000000..b2b483a1 --- /dev/null +++ b/codex-lens-v2/src/codexlens_search/watcher/events.py @@ -0,0 +1,57 @@ +"""Event types for file watcher.""" +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Optional, Set + + +class ChangeType(Enum): + """Type of file system change.""" + + CREATED = "created" + MODIFIED = "modified" + DELETED = "deleted" + + +@dataclass +class FileEvent: + """A file system change event.""" + + path: Path + change_type: ChangeType + timestamp: float = field(default_factory=time.time) + + +@dataclass +class WatcherConfig: + """Configuration for file watcher. + + Attributes: + debounce_ms: Milliseconds to wait after the last event before + flushing the batch. Default 500ms for low-latency indexing. + ignored_patterns: Directory/file name patterns to skip. Any + path component matching one of these strings is ignored. + """ + + debounce_ms: int = 500 + ignored_patterns: Set[str] = field(default_factory=lambda: { + # Version control + ".git", ".svn", ".hg", + # Python + ".venv", "venv", "env", "__pycache__", ".pytest_cache", + ".mypy_cache", ".ruff_cache", + # Node.js + "node_modules", "bower_components", + # Build artifacts + "dist", "build", "out", "target", "bin", "obj", + "coverage", "htmlcov", + # IDE / Editor + ".idea", ".vscode", ".vs", + # Package / cache + ".cache", ".parcel-cache", ".turbo", ".next", ".nuxt", + # Logs / temp + "logs", "tmp", "temp", + }) diff --git a/codex-lens-v2/src/codexlens_search/watcher/file_watcher.py b/codex-lens-v2/src/codexlens_search/watcher/file_watcher.py new file mode 100644 index 00000000..7fd8473f --- /dev/null +++ b/codex-lens-v2/src/codexlens_search/watcher/file_watcher.py @@ -0,0 +1,263 @@ +"""File system watcher using watchdog library. + +Ported from codex-lens v1 with simplifications: +- Removed v1-specific Config dependency (uses WatcherConfig directly) +- Removed MAX_QUEUE_SIZE (v2 processes immediately via debounce) +- Removed flush.signal file mechanism +- Added optional JSONL output mode for bridge CLI integration +""" +from __future__ import annotations + +import json +import logging +import sys +import threading +import time +from pathlib import Path +from typing import Callable, Dict, List, Optional + +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + +from .events import ChangeType, FileEvent, WatcherConfig + +logger = logging.getLogger(__name__) + + +# Event priority for deduplication: higher wins when same file appears +# multiple times within one debounce window. +_EVENT_PRIORITY: Dict[ChangeType, int] = { + ChangeType.CREATED: 1, + ChangeType.MODIFIED: 2, + ChangeType.DELETED: 3, +} + + +class _Handler(FileSystemEventHandler): + """Internal watchdog handler that converts events to FileEvent.""" + + def __init__(self, watcher: FileWatcher) -> None: + super().__init__() + self._watcher = watcher + + def on_created(self, event) -> None: + if not event.is_directory: + self._watcher._on_raw_event(event.src_path, ChangeType.CREATED) + + def on_modified(self, event) -> None: + if not event.is_directory: + self._watcher._on_raw_event(event.src_path, ChangeType.MODIFIED) + + def on_deleted(self, event) -> None: + if not event.is_directory: + self._watcher._on_raw_event(event.src_path, ChangeType.DELETED) + + def on_moved(self, event) -> None: + if event.is_directory: + return + # Treat move as delete old + create new + self._watcher._on_raw_event(event.src_path, ChangeType.DELETED) + self._watcher._on_raw_event(event.dest_path, ChangeType.CREATED) + + +class FileWatcher: + """File system watcher with debounce and event deduplication. + + Monitors a directory recursively using watchdog. Raw events are + collected into a queue. After *debounce_ms* of silence the queue + is flushed: events are deduplicated per-path (keeping the highest + priority change type) and delivered via *on_changes*. + + Example:: + + def handle(events: list[FileEvent]) -> None: + for e in events: + print(e.change_type.value, e.path) + + watcher = FileWatcher(Path("."), WatcherConfig(), handle) + watcher.start() + watcher.wait() + """ + + def __init__( + self, + root_path: Path, + config: WatcherConfig, + on_changes: Callable[[List[FileEvent]], None], + ) -> None: + self.root_path = Path(root_path).resolve() + self.config = config + self.on_changes = on_changes + + self._observer: Optional[Observer] = None + self._running = False + self._stop_event = threading.Event() + self._lock = threading.RLock() + + # Pending events keyed by resolved path + self._pending: Dict[Path, FileEvent] = {} + self._pending_lock = threading.Lock() + + # True-debounce timer: resets on every new event + self._flush_timer: Optional[threading.Timer] = None + + # ------------------------------------------------------------------ + # Filtering + # ------------------------------------------------------------------ + + def _should_watch(self, path: Path) -> bool: + """Return True if *path* should not be ignored.""" + parts = path.parts + for pattern in self.config.ignored_patterns: + if pattern in parts: + return False + return True + + # ------------------------------------------------------------------ + # Event intake (called from watchdog thread) + # ------------------------------------------------------------------ + + def _on_raw_event(self, raw_path: str, change_type: ChangeType) -> None: + """Accept a raw watchdog event, filter, and queue with debounce.""" + path = Path(raw_path).resolve() + + if not self._should_watch(path): + return + + event = FileEvent(path=path, change_type=change_type) + + with self._pending_lock: + existing = self._pending.get(path) + if existing is None or _EVENT_PRIORITY[change_type] >= _EVENT_PRIORITY[existing.change_type]: + self._pending[path] = event + + # Cancel previous timer and start a new one (true debounce) + if self._flush_timer is not None: + self._flush_timer.cancel() + + self._flush_timer = threading.Timer( + self.config.debounce_ms / 1000.0, + self._flush, + ) + self._flush_timer.daemon = True + self._flush_timer.start() + + # ------------------------------------------------------------------ + # Flush + # ------------------------------------------------------------------ + + def _flush(self) -> None: + """Deduplicate and deliver pending events.""" + with self._pending_lock: + if not self._pending: + return + events = list(self._pending.values()) + self._pending.clear() + self._flush_timer = None + + try: + self.on_changes(events) + except Exception: + logger.exception("Error in on_changes callback") + + def flush_now(self) -> None: + """Immediately flush pending events (manual trigger).""" + with self._pending_lock: + if self._flush_timer is not None: + self._flush_timer.cancel() + self._flush_timer = None + self._flush() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def start(self) -> None: + """Start watching the directory (non-blocking).""" + with self._lock: + if self._running: + logger.warning("Watcher already running") + return + + if not self.root_path.exists(): + raise ValueError(f"Root path does not exist: {self.root_path}") + + self._observer = Observer() + handler = _Handler(self) + self._observer.schedule(handler, str(self.root_path), recursive=True) + + self._running = True + self._stop_event.clear() + self._observer.start() + logger.info("Started watching: %s", self.root_path) + + def stop(self) -> None: + """Stop watching and flush remaining events.""" + with self._lock: + if not self._running: + return + + self._running = False + self._stop_event.set() + + with self._pending_lock: + if self._flush_timer is not None: + self._flush_timer.cancel() + self._flush_timer = None + + if self._observer is not None: + self._observer.stop() + self._observer.join(timeout=5.0) + self._observer = None + + # Deliver any remaining events + self._flush() + logger.info("Stopped watching: %s", self.root_path) + + def wait(self) -> None: + """Block until stopped (Ctrl+C or stop() from another thread).""" + try: + while self._running: + self._stop_event.wait(timeout=1.0) + except KeyboardInterrupt: + logger.info("Received interrupt, stopping watcher...") + self.stop() + + @property + def is_running(self) -> bool: + """True if the watcher is currently running.""" + return self._running + + # ------------------------------------------------------------------ + # JSONL output helper + # ------------------------------------------------------------------ + + @staticmethod + def events_to_jsonl(events: List[FileEvent]) -> str: + """Serialize a batch of events as newline-delimited JSON. + + Each line is a JSON object with keys: ``path``, ``change_type``, + ``timestamp``. Useful for bridge CLI integration. + """ + lines: list[str] = [] + for evt in events: + obj = { + "path": str(evt.path), + "change_type": evt.change_type.value, + "timestamp": evt.timestamp, + } + lines.append(json.dumps(obj, ensure_ascii=False)) + return "\n".join(lines) + + @staticmethod + def jsonl_callback(events: List[FileEvent]) -> None: + """Callback that writes JSONL to stdout. + + Suitable as *on_changes* when running in bridge/CLI mode:: + + watcher = FileWatcher(root, config, FileWatcher.jsonl_callback) + """ + output = FileWatcher.events_to_jsonl(events) + if output: + sys.stdout.write(output + "\n") + sys.stdout.flush() diff --git a/codex-lens-v2/src/codexlens_search/watcher/incremental_indexer.py b/codex-lens-v2/src/codexlens_search/watcher/incremental_indexer.py new file mode 100644 index 00000000..d0d3f265 --- /dev/null +++ b/codex-lens-v2/src/codexlens_search/watcher/incremental_indexer.py @@ -0,0 +1,129 @@ +"""Incremental indexer that processes FileEvents via IndexingPipeline. + +Ported from codex-lens v1 with simplifications: +- Uses IndexingPipeline.index_file() / remove_file() directly +- No v1-specific Config, ParserFactory, DirIndexStore dependencies +- Per-file error isolation: one failure does not stop batch processing +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +from codexlens_search.indexing.pipeline import IndexingPipeline + +from .events import ChangeType, FileEvent + +logger = logging.getLogger(__name__) + + +@dataclass +class BatchResult: + """Result of processing a batch of file events.""" + + files_indexed: int = 0 + files_removed: int = 0 + chunks_created: int = 0 + errors: List[str] = field(default_factory=list) + + @property + def total_processed(self) -> int: + return self.files_indexed + self.files_removed + + @property + def has_errors(self) -> bool: + return len(self.errors) > 0 + + +class IncrementalIndexer: + """Routes file change events to IndexingPipeline operations. + + CREATED / MODIFIED events call ``pipeline.index_file()``. + DELETED events call ``pipeline.remove_file()``. + + Each file is processed in isolation so that a single failure + does not prevent the rest of the batch from being indexed. + + Example:: + + indexer = IncrementalIndexer(pipeline, root=Path("/project")) + result = indexer.process_events([ + FileEvent(Path("src/main.py"), ChangeType.MODIFIED), + ]) + print(f"Indexed {result.files_indexed}, removed {result.files_removed}") + """ + + def __init__( + self, + pipeline: IndexingPipeline, + *, + root: Optional[Path] = None, + ) -> None: + """Initialize the incremental indexer. + + Args: + pipeline: The indexing pipeline with metadata store configured. + root: Optional project root for computing relative paths. + If None, absolute paths are used as identifiers. + """ + self._pipeline = pipeline + self._root = root + + def process_events(self, events: List[FileEvent]) -> BatchResult: + """Process a batch of file events with per-file error isolation. + + Args: + events: List of file events to process. + + Returns: + BatchResult with per-batch statistics. + """ + result = BatchResult() + + for event in events: + try: + if event.change_type in (ChangeType.CREATED, ChangeType.MODIFIED): + self._handle_index(event, result) + elif event.change_type == ChangeType.DELETED: + self._handle_remove(event, result) + except Exception as exc: + error_msg = ( + f"Error processing {event.path} " + f"({event.change_type.value}): " + f"{type(exc).__name__}: {exc}" + ) + logger.error(error_msg) + result.errors.append(error_msg) + + if result.total_processed > 0: + logger.info( + "Batch complete: %d indexed, %d removed, %d errors", + result.files_indexed, + result.files_removed, + len(result.errors), + ) + + return result + + def _handle_index(self, event: FileEvent, result: BatchResult) -> None: + """Index a created or modified file.""" + stats = self._pipeline.index_file( + event.path, + root=self._root, + force=(event.change_type == ChangeType.MODIFIED), + ) + if stats.files_processed > 0: + result.files_indexed += 1 + result.chunks_created += stats.chunks_created + + def _handle_remove(self, event: FileEvent, result: BatchResult) -> None: + """Remove a deleted file from the index.""" + rel_path = ( + str(event.path.relative_to(self._root)) + if self._root + else str(event.path) + ) + self._pipeline.remove_file(rel_path) + result.files_removed += 1 diff --git a/codex-lens-v2/tests/unit/test_incremental.py b/codex-lens-v2/tests/unit/test_incremental.py new file mode 100644 index 00000000..8627831f --- /dev/null +++ b/codex-lens-v2/tests/unit/test_incremental.py @@ -0,0 +1,388 @@ +"""Unit tests for IndexingPipeline incremental API (index_file, remove_file, sync, compact).""" +from __future__ import annotations + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from codexlens_search.config import Config +from codexlens_search.core.binary import BinaryStore +from codexlens_search.core.index import ANNIndex +from codexlens_search.embed.base import BaseEmbedder +from codexlens_search.indexing.metadata import MetadataStore +from codexlens_search.indexing.pipeline import IndexingPipeline, IndexStats +from codexlens_search.search.fts import FTSEngine + + +DIM = 32 + + +class FakeEmbedder(BaseEmbedder): + """Deterministic embedder for testing.""" + + def __init__(self) -> None: + pass + + def embed_single(self, text: str) -> np.ndarray: + rng = np.random.default_rng(hash(text) % (2**31)) + return rng.standard_normal(DIM).astype(np.float32) + + def embed_batch(self, texts: list[str]) -> list[np.ndarray]: + return [self.embed_single(t) for t in texts] + + +@pytest.fixture +def workspace(tmp_path: Path): + """Create workspace with stores, metadata, and pipeline.""" + cfg = Config.small() + # Override embed_dim to match our test dim + cfg.embed_dim = DIM + + store_dir = tmp_path / "stores" + store_dir.mkdir() + + binary_store = BinaryStore(store_dir, DIM, cfg) + ann_index = ANNIndex(store_dir, DIM, cfg) + fts = FTSEngine(str(store_dir / "fts.db")) + metadata = MetadataStore(str(store_dir / "metadata.db")) + embedder = FakeEmbedder() + + pipeline = IndexingPipeline( + embedder=embedder, + binary_store=binary_store, + ann_index=ann_index, + fts=fts, + config=cfg, + metadata=metadata, + ) + + # Create sample source files + src_dir = tmp_path / "src" + src_dir.mkdir() + + return { + "pipeline": pipeline, + "metadata": metadata, + "binary_store": binary_store, + "ann_index": ann_index, + "fts": fts, + "src_dir": src_dir, + "store_dir": store_dir, + "config": cfg, + } + + +def _write_file(src_dir: Path, name: str, content: str) -> Path: + """Write a file and return its path.""" + p = src_dir / name + p.write_text(content, encoding="utf-8") + return p + + +# --------------------------------------------------------------------------- +# MetadataStore helper method tests +# --------------------------------------------------------------------------- + + +class TestMetadataHelpers: + def test_get_all_files_empty(self, workspace): + meta = workspace["metadata"] + assert meta.get_all_files() == {} + + def test_get_all_files_after_register(self, workspace): + meta = workspace["metadata"] + meta.register_file("a.py", "hash_a", 1000.0) + meta.register_file("b.py", "hash_b", 2000.0) + result = meta.get_all_files() + assert result == {"a.py": "hash_a", "b.py": "hash_b"} + + def test_max_chunk_id_empty(self, workspace): + meta = workspace["metadata"] + assert meta.max_chunk_id() == -1 + + def test_max_chunk_id_with_chunks(self, workspace): + meta = workspace["metadata"] + meta.register_file("a.py", "hash_a", 1000.0) + meta.register_chunks("a.py", [(0, "h0"), (1, "h1"), (5, "h5")]) + assert meta.max_chunk_id() == 5 + + def test_max_chunk_id_includes_deleted(self, workspace): + meta = workspace["metadata"] + meta.register_file("a.py", "hash_a", 1000.0) + meta.register_chunks("a.py", [(0, "h0"), (3, "h3")]) + meta.mark_file_deleted("a.py") + # Chunks moved to deleted_chunks, max should still be 3 + assert meta.max_chunk_id() == 3 + + +# --------------------------------------------------------------------------- +# index_file tests +# --------------------------------------------------------------------------- + + +class TestIndexFile: + def test_index_file_basic(self, workspace): + pipeline = workspace["pipeline"] + meta = workspace["metadata"] + src_dir = workspace["src_dir"] + + f = _write_file(src_dir, "hello.py", "print('hello world')\n") + stats = pipeline.index_file(f, root=src_dir) + + assert stats.files_processed == 1 + assert stats.chunks_created >= 1 + assert meta.get_file_hash("hello.py") is not None + assert len(meta.get_chunk_ids_for_file("hello.py")) >= 1 + + def test_index_file_skips_unchanged(self, workspace): + pipeline = workspace["pipeline"] + src_dir = workspace["src_dir"] + + f = _write_file(src_dir, "same.py", "x = 1\n") + stats1 = pipeline.index_file(f, root=src_dir) + assert stats1.files_processed == 1 + + stats2 = pipeline.index_file(f, root=src_dir) + assert stats2.files_processed == 0 + assert stats2.chunks_created == 0 + + def test_index_file_force_reindex(self, workspace): + pipeline = workspace["pipeline"] + src_dir = workspace["src_dir"] + + f = _write_file(src_dir, "force.py", "x = 1\n") + pipeline.index_file(f, root=src_dir) + + stats = pipeline.index_file(f, root=src_dir, force=True) + assert stats.files_processed == 1 + assert stats.chunks_created >= 1 + + def test_index_file_updates_changed_file(self, workspace): + pipeline = workspace["pipeline"] + meta = workspace["metadata"] + src_dir = workspace["src_dir"] + + f = _write_file(src_dir, "changing.py", "version = 1\n") + pipeline.index_file(f, root=src_dir) + old_chunks = meta.get_chunk_ids_for_file("changing.py") + + # Modify file + f.write_text("version = 2\nmore code\n", encoding="utf-8") + stats = pipeline.index_file(f, root=src_dir) + assert stats.files_processed == 1 + + new_chunks = meta.get_chunk_ids_for_file("changing.py") + # Old chunks should have been tombstoned, new ones assigned + assert set(old_chunks) != set(new_chunks) + + def test_index_file_registers_in_metadata(self, workspace): + pipeline = workspace["pipeline"] + meta = workspace["metadata"] + fts = workspace["fts"] + src_dir = workspace["src_dir"] + + f = _write_file(src_dir, "meta_test.py", "def foo(): pass\n") + pipeline.index_file(f, root=src_dir) + + # MetadataStore has file registered + assert meta.get_file_hash("meta_test.py") is not None + chunk_ids = meta.get_chunk_ids_for_file("meta_test.py") + assert len(chunk_ids) >= 1 + + # FTS has the content + fts_ids = fts.get_chunk_ids_by_path("meta_test.py") + assert len(fts_ids) >= 1 + + def test_index_file_no_metadata_raises(self, workspace): + cfg = workspace["config"] + pipeline_no_meta = IndexingPipeline( + embedder=FakeEmbedder(), + binary_store=workspace["binary_store"], + ann_index=workspace["ann_index"], + fts=workspace["fts"], + config=cfg, + ) + f = _write_file(workspace["src_dir"], "no_meta.py", "x = 1\n") + with pytest.raises(RuntimeError, match="MetadataStore is required"): + pipeline_no_meta.index_file(f) + + +# --------------------------------------------------------------------------- +# remove_file tests +# --------------------------------------------------------------------------- + + +class TestRemoveFile: + def test_remove_file_tombstones_and_fts(self, workspace): + pipeline = workspace["pipeline"] + meta = workspace["metadata"] + fts = workspace["fts"] + src_dir = workspace["src_dir"] + + f = _write_file(src_dir, "to_remove.py", "data = [1, 2, 3]\n") + pipeline.index_file(f, root=src_dir) + + chunk_ids = meta.get_chunk_ids_for_file("to_remove.py") + assert len(chunk_ids) >= 1 + + pipeline.remove_file("to_remove.py") + + # File should be gone from metadata + assert meta.get_file_hash("to_remove.py") is None + assert meta.get_chunk_ids_for_file("to_remove.py") == [] + + # Chunks should be in deleted_chunks + deleted = meta.get_deleted_ids() + for cid in chunk_ids: + assert cid in deleted + + # FTS should be cleared + assert fts.get_chunk_ids_by_path("to_remove.py") == [] + + def test_remove_nonexistent_file(self, workspace): + pipeline = workspace["pipeline"] + # Should not raise + pipeline.remove_file("nonexistent.py") + + +# --------------------------------------------------------------------------- +# sync tests +# --------------------------------------------------------------------------- + + +class TestSync: + def test_sync_indexes_new_files(self, workspace): + pipeline = workspace["pipeline"] + meta = workspace["metadata"] + src_dir = workspace["src_dir"] + + f1 = _write_file(src_dir, "a.py", "a = 1\n") + f2 = _write_file(src_dir, "b.py", "b = 2\n") + + stats = pipeline.sync([f1, f2], root=src_dir) + assert stats.files_processed == 2 + assert meta.get_file_hash("a.py") is not None + assert meta.get_file_hash("b.py") is not None + + def test_sync_removes_missing_files(self, workspace): + pipeline = workspace["pipeline"] + meta = workspace["metadata"] + src_dir = workspace["src_dir"] + + f1 = _write_file(src_dir, "keep.py", "keep = True\n") + f2 = _write_file(src_dir, "remove.py", "remove = True\n") + + pipeline.sync([f1, f2], root=src_dir) + assert meta.get_file_hash("remove.py") is not None + + # Sync with only f1 -- f2 should be removed + stats = pipeline.sync([f1], root=src_dir) + assert meta.get_file_hash("remove.py") is None + deleted = meta.get_deleted_ids() + assert len(deleted) > 0 + + def test_sync_detects_changed_files(self, workspace): + pipeline = workspace["pipeline"] + meta = workspace["metadata"] + src_dir = workspace["src_dir"] + + f = _write_file(src_dir, "mutable.py", "v1\n") + pipeline.sync([f], root=src_dir) + old_hash = meta.get_file_hash("mutable.py") + + f.write_text("v2\n", encoding="utf-8") + stats = pipeline.sync([f], root=src_dir) + assert stats.files_processed == 1 + new_hash = meta.get_file_hash("mutable.py") + assert old_hash != new_hash + + def test_sync_skips_unchanged(self, workspace): + pipeline = workspace["pipeline"] + src_dir = workspace["src_dir"] + + f = _write_file(src_dir, "stable.py", "stable = True\n") + pipeline.sync([f], root=src_dir) + + # Second sync with same file, unchanged + stats = pipeline.sync([f], root=src_dir) + assert stats.files_processed == 0 + assert stats.chunks_created == 0 + + +# --------------------------------------------------------------------------- +# compact tests +# --------------------------------------------------------------------------- + + +class TestCompact: + def test_compact_removes_tombstoned_from_binary_store(self, workspace): + pipeline = workspace["pipeline"] + meta = workspace["metadata"] + binary_store = workspace["binary_store"] + src_dir = workspace["src_dir"] + + f1 = _write_file(src_dir, "alive.py", "alive = True\n") + f2 = _write_file(src_dir, "dead.py", "dead = True\n") + + pipeline.index_file(f1, root=src_dir) + pipeline.index_file(f2, root=src_dir) + + count_before = binary_store._count + assert count_before >= 2 + + pipeline.remove_file("dead.py") + pipeline.compact() + + # BinaryStore should have fewer entries + assert binary_store._count < count_before + # deleted_chunks should be cleared + assert meta.get_deleted_ids() == set() + + def test_compact_noop_when_no_deletions(self, workspace): + pipeline = workspace["pipeline"] + meta = workspace["metadata"] + binary_store = workspace["binary_store"] + src_dir = workspace["src_dir"] + + f = _write_file(src_dir, "solo.py", "solo = True\n") + pipeline.index_file(f, root=src_dir) + count_before = binary_store._count + + pipeline.compact() + assert binary_store._count == count_before + + +# --------------------------------------------------------------------------- +# Backward compatibility: existing batch API still works +# --------------------------------------------------------------------------- + + +class TestBatchAPIUnchanged: + def test_index_files_still_works(self, workspace): + pipeline = workspace["pipeline"] + src_dir = workspace["src_dir"] + + f1 = _write_file(src_dir, "batch1.py", "batch1 = 1\n") + f2 = _write_file(src_dir, "batch2.py", "batch2 = 2\n") + + stats = pipeline.index_files([f1, f2], root=src_dir) + assert stats.files_processed == 2 + assert stats.chunks_created >= 2 + + def test_index_files_works_without_metadata(self, workspace): + """Batch API should work even without MetadataStore.""" + cfg = workspace["config"] + pipeline_no_meta = IndexingPipeline( + embedder=FakeEmbedder(), + binary_store=BinaryStore(workspace["store_dir"] / "no_meta", DIM, cfg), + ann_index=ANNIndex(workspace["store_dir"] / "no_meta", DIM, cfg), + fts=FTSEngine(str(workspace["store_dir"] / "no_meta_fts.db")), + config=cfg, + ) + src_dir = workspace["src_dir"] + f = _write_file(src_dir, "no_meta_batch.py", "x = 1\n") + stats = pipeline_no_meta.index_files([f], root=src_dir) + assert stats.files_processed == 1