Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .opencode/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# AGENTS.md

> **CRITICAL:** THIS REPOSITORY IS STRICTLY GOVERNED BY THE PIZENLABS DUAL-ENGINE SECOND BRAIN PROTOCOL.
> YOU ARE ABSOLUTELY FORBIDDEN FROM MAKING ANY BLIND SEARCHES (`GLOB`, `GREP`), RAW FILE READS, OR CODE EDITS UNTIL YOU COMPLY WITH THE ECOSYSTEM LIFECYCLE BELOW.

---

## INITIALIZATION LIFECYCLE (MANDATORY FIRST ACTIONS)

Before executing any business logic or making any architectural assumptions, you **MUST** sequentially call your native read tool to parse the following core state configurations inside `.lea/`:

1. **`.lea/protocol.json`**

* Ingest the `"strict_runtime_rules"` and `"tool_execution_adapter"`.
* Treat them as unchangeable runtime constants.
* **CRITICAL BINDING:** You **MUST** execute all ecosystem subcommands through your native `bash` tool using the exact format: `bash -c '<command>'`.

2. **`.lea/workspace.json`**

* Ingest the immutable repository facts, languages, frameworks, and graph stats to understand the project scale.

3. **`.lea/intent.json` & `.lea/limitations.json`**

* Ingest current human architectural boundaries, unsupported scopes, and confidence limitations (especially regarding dynamic dispatch).

4. **`.lea/memory.json`**

* Inherit historical failures, hotspots, and successful patterns to avoid repeating past development bugs.

---

## MANDATORY EXECUTION PIPELINE (NEVER BYPASS)

Every single task requiring file exploration or modification **MUST** navigate through the strict multi-phase pipeline defined in `protocol.json`:

### PHASE 1: DISCOVER (via `pizen-lynx` engine)

You **MUST** invoke discovery commands via bash first to retrieve coordinate states:

* `bash -c 'lx search <query>'` — Discover candidate symbols/files by intent.
* `bash -c 'lx resolve <name>'` — Resolve a symbol to stable file coordinates.
* *Zero modifications or raw file reads are allowed before this phase provides valid coordinates.*

### PHASE 2: REASON (via `pizen-lea` engine)

Once coordinates are resolved, you **MUST** pipe them into the graph reasoning layer via bash to map structural impact and execution flows:

* `bash -c 'lea context <symbol>'` — Compile token-budgeted context blocks for the target symbol.
* `bash -c 'lea flow <symbol>'` — Generate ordered control-flow trace from the target symbol.
* `bash -c 'lea impact <symbol>'` — Execute recursive blast-radius impact analysis.

### PHASE 3: ARCHITECTURE GUARD

Before completing any task or declaring success, you **MUST** validate project boundaries:

* `bash -c 'lea violations'`
* Ensure that all your changes comply strictly with the boundary constraints and return zero violations.

---

## HARD BOUNDARIES & ABORT CONDITIONS

* **Abort Trigger:** If you hit **3 consecutive failed edits**, you **MUST** immediately halt all file mutations.

* **Recovery Procedure:** Run the fallback sequence through your native bash tool:

1. `bash -c 'lx resolve <failed_symbol>'`
2. `bash -c 'lea impact <failed_symbol>'`
3. **Request human intervention** as specified in `protocol.json`.

> **CONSEQUENCE:** Failure to execute this exact setup loop and orchestration pipeline as your **FIRST** tool actions will break repository integrity and result in immediate session rejection.
140 changes: 140 additions & 0 deletions internal/cli/commands/hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package commands

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/PizenLabs/lea/internal/storage/sqlite"
"github.com/spf13/cobra"
)

// HookInput represents the JSON payload passed to PreToolUse hooks by Claude Code and other agents.
type HookInput struct {
ToolName string `json:"tool_name"`
ToolNameCamel string `json:"toolName"`
ToolInput map[string]any `json:"tool_input"`
ToolInputCamel map[string]any `json:"toolInput"`
}

var hookCmd = &cobra.Command{
Use: "hook",
Short: "Run tool execution hooks for AI coding agents",
Long: `The hook command intercepts and validates agent tool calls in the lifecycle.`,
}

var hookPreToolCmd = &cobra.Command{
Use: "pre-tool",
Short: "Execute pre-tool lifecycle checks",
Run: func(_ *cobra.Command, _ []string) {
// Read hook input from stdin
stdinData, err := io.ReadAll(os.Stdin)
if err != nil {
// Fail open on error reading stdin to avoid blocking developer flow
os.Exit(0)
}

if len(stdinData) == 0 {
os.Exit(0)
}

var input HookInput
if err := json.Unmarshal(stdinData, &input); err != nil {
// Fail open on invalid JSON
os.Exit(0)
}

toolName := input.ToolName
if toolName == "" {
toolName = input.ToolNameCamel
}

// Clean up toolName
toolName = strings.TrimSpace(toolName)
if toolName == "" {
os.Exit(0)
}

// Check if it's a pizen-lea tool.
// pizen-lea tools typically include: impact, flow, neighbors, violations, symbols
// They can be named like: "pizen-lea__impact", "mcp__pizen-lea__impact", "pizen-lea/impact", "impact"
isLeaTool := false
lowerName := strings.ToLower(toolName)
if strings.Contains(lowerName, "pizen-lea") {
isLeaTool = true
} else {
// Fallback: check if the tool name matches one of our MCP tools
leaTools := []string{"impact", "flow", "neighbors", "violations", "symbols"}
for _, t := range leaTools {
if lowerName == t || strings.HasSuffix(lowerName, "__"+t) || strings.HasSuffix(lowerName, "/"+t) {
isLeaTool = true
break
}
}
}

if !isLeaTool {
os.Exit(0)
}

// It's a pizen-lea tool. Get tool input.
toolInput := input.ToolInput
if len(toolInput) == 0 {
toolInput = input.ToolInputCamel
}

// Try to extract the symbol name.
// The tool arguments might be: symbol, symbol_id, target, name, qualified_name
var symbolName string
symbolKeys := []string{"symbol", "symbol_id", "target", "name", "qualified_name", "symbolName", "symbolId"}
for _, k := range symbolKeys {
if val, ok := toolInput[k]; ok {
if s, ok := val.(string); ok && s != "" {
symbolName = s
break
}
}
}

if symbolName == "" {
// If no symbol name is passed, let it pass to standard validation/execution
os.Exit(0)
}

// Find .lea directory starting from current working directory
leaDir, err := findLeaDir(".")
if err != nil {
// If .lea dir doesn't exist, we can't validate, so fail open
os.Exit(0)
}

dbPath := filepath.Join(leaDir, "graph.db")
store, err := sqlite.NewStore(dbPath)
if err != nil {
os.Exit(0)
}
defer func() { _ = store.Close() }()

ctx := context.Background()
_, err = resolveSymbolID(ctx, store, symbolName)
if err != nil {
// Symbol resolution failed! Block the tool execution (exit code 2)
// and print the warning message to stderr.
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
fmt.Fprintln(os.Stderr, "CRITICAL: You MUST first search for the symbol using pizen-lynx (via search or resolve) to find the correct Symbol ID before calling pizen-lea tools.")
os.Exit(2)
}

// Success, allow tool execution
os.Exit(0)
},
}

func init() {
rootCmd.AddCommand(hookCmd)
hookCmd.AddCommand(hookPreToolCmd)
}
48 changes: 48 additions & 0 deletions internal/cli/commands/hook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package commands

import (
"bytes"

Check failure on line 4 in internal/cli/commands/hook_test.go

View workflow job for this annotation

GitHub Actions / Lint, Test, Build

"bytes" imported and not used
"io"

Check failure on line 5 in internal/cli/commands/hook_test.go

View workflow job for this annotation

GitHub Actions / Lint, Test, Build

"io" imported and not used
"os"
"testing"
)

// Mockable os.Exit for testing
var osExitOriginal = os.Exit
var osExit = os.Exit

func TestHookPreToolInvalidSymbol(t *testing.T) {
// Prepare JSON input with a non-existent symbol
jsonInput := []byte(`{"tool_name":"pizen-lea__impact","tool_input":{"symbol":"nonexistent_symbol"}}`)
// Replace stdin with pipe containing JSON input
oldStdin := os.Stdin
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe error: %v", err)
}
_, err = w.Write(jsonInput)
if err != nil {
t.Fatalf("write error: %v", err)
}
w.Close()
os.Stdin = r
// Capture exit code via mocking os.Exit
exitCode := 0
osExit = func(code int) {
exitCode = code
panic("os.Exit called")
}
defer func() {
// Restore globals
os.Stdin = oldStdin
os.Exit = osExitOriginal

Check failure on line 38 in internal/cli/commands/hook_test.go

View workflow job for this annotation

GitHub Actions / Lint, Test, Build

cannot assign to os.Exit (neither addressable nor a map index expression) (typecheck)

Check failure on line 38 in internal/cli/commands/hook_test.go

View workflow job for this annotation

GitHub Actions / Lint, Test, Build

use of package os not in selector
if r := recover(); r != nil {
// expected panic from os.Exit
}
if exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}
}()
// Run the pre-tool hook command
hookPreToolCmd.Run(nil, []string{})
}
26 changes: 11 additions & 15 deletions internal/cli/commands/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -418,15 +418,12 @@ func resolveInterfaceImplementations(ctx context.Context, store contracts.Store)

// Build map: typeID -> set of methodIDs belonging to it
typeMethods := make(map[string]map[string]bool)
// Build reverse map: methodID -> belongs to typeID
methodOwner := make(map[string]string)

for _, e := range belongsToEdges {
// BELONGS_TO goes from method -> type (or type -> package, struct -> package)
// We need edges from method to type
// Check if FromID looks like a method ID (starts with "method:")
if strings.HasPrefix(e.FromID, "method:") {
methodOwner[e.FromID] = e.ToID
if typeMethods[e.ToID] == nil {
typeMethods[e.ToID] = make(map[string]bool)
}
Expand Down Expand Up @@ -472,7 +469,8 @@ func resolveInterfaceImplementations(ctx context.Context, store contracts.Store)
}

// Count new edges for reporting
newEdges := 0
var newEdges []*graph.Edge
newMethodEdges := 0

// For each interface, find structs whose method set is a superset of the interface method set
for ifaceID, ifaceMethods := range interfaceMethods {
Expand All @@ -492,11 +490,7 @@ func resolveInterfaceImplementations(ctx context.Context, store contracts.Store)
ToID: ifaceID,
Type: graph.EdgeImplements,
}
if err := store.SaveEdge(ctx, edge); err != nil {
return fmt.Errorf("failed to save IMPLEMENTS edge from %s to %s: %w", structID, ifaceID, err)
}
newEdges++
fmt.Printf(" IMPLEMENTS: %s -> %s\n", structID, ifaceID)
newEdges = append(newEdges, edge)

// Pass 2: Explicit method-to-interface linking (Issue 1 fix)
// Create IMPLEMENTS_METHOD edges at the method granularity
Expand All @@ -523,18 +517,20 @@ func resolveInterfaceImplementations(ctx context.Context, store contracts.Store)
ToID: ifaceMethodID,
Type: graph.EdgeImplementsMethod,
}
if err := store.SaveEdge(ctx, methodEdge); err != nil {
return fmt.Errorf("failed to save IMPLEMENTS_METHOD edge from %s to %s: %w", concreteMethodID, ifaceMethodID, err)
}
fmt.Printf(" IMPLEMENTS_METHOD: %s -> %s\n", concreteMethodID, ifaceMethodID)
newEdges = append(newEdges, methodEdge)
newMethodEdges++
}
}
}
}
}

if newEdges > 0 {
fmt.Printf("Resolved %d interface implementation(s).\n", newEdges)
if len(newEdges) > 0 {
// Batch all new edges in a single transaction
if err := store.SaveGraph(ctx, nil, newEdges); err != nil {
return fmt.Errorf("failed to save interface implementation edges: %w", err)
}
fmt.Printf("Resolved %d interface implementation(s) and %d method edge(s).\n", len(newEdges)-newMethodEdges, newMethodEdges)
}

return nil
Expand Down
17 changes: 14 additions & 3 deletions internal/cli/commands/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,29 @@ var mcpCmd = &cobra.Command{
},
}

var (
mcpInstallYes bool
mcpInstallAll bool
)

var mcpInstallCmd = &cobra.Command{
Use: "install",
Short: "Configure MCP entries for lea and lx across AI tools",
Long: `Installs MCP server entries (pizen-lea and pizen-lynx) into the configuration
files of supported AI coding agents: Claude Code, VS Code (Cline/Roo Code/Codex CLI),
OpenCode, Pi, Zed, Gemini CLI, OpenClaw, Aider, Antigravity, Kiro, and KiloCode.`,
files of supported AI coding agents: Claude Code, Codex CLI, Gemini CLI, Zed,
OpenCode, Antigravity, Aider, KiloCode, VS Code, OpenClaw, Kiro, and Pi.

Only agents whose global/home configuration directory exists on the system are
detected. Use --yes or --all to configure all detected agents without prompting.`,
RunE: func(_ *cobra.Command, _ []string) error {
return install.Run()
auto := mcpInstallYes || mcpInstallAll
return install.Run(install.Options{AutoSelectAll: auto})
},
}

func init() {
rootCmd.AddCommand(mcpCmd)
mcpCmd.AddCommand(mcpInstallCmd)
mcpInstallCmd.Flags().BoolVarP(&mcpInstallYes, "yes", "y", false, "configure all detected agents without prompting")
mcpInstallCmd.Flags().BoolVar(&mcpInstallAll, "all", false, "configure all detected agents without prompting")
}
Loading
Loading