diff --git a/internal/agents/context_reducer.go b/internal/agents/context_reducer.go new file mode 100644 index 0000000..aa3bede --- /dev/null +++ b/internal/agents/context_reducer.go @@ -0,0 +1,122 @@ +package agents + +import ( + "regexp" + "strings" +) + +// testLogPatterns match lines commonly found in go test / compiler output +// that carry no structural value for a direct mutation plan. +var testLogPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)^ok\s+`), + regexp.MustCompile(`(?i)^FAIL\s+`), + regexp.MustCompile(`(?i)^---\s+(PASS|FAIL|SKIP)`), + regexp.MustCompile(`(?i)^\?`), + regexp.MustCompile(`(?i)^=== RUN`), + regexp.MustCompile(`(?i)^\s+.*_test\.go:`), + regexp.MustCompile(`(?i)panic:`), + regexp.MustCompile(`(?i)goroutine\s+\d+`), + regexp.MustCompile(`(?i)created by`), + regexp.MustCompile(`(?i)go: downloading`), + regexp.MustCompile(`(?i)go: extracting`), + regexp.MustCompile(`(?i)go: finding`), +} + +// runtimeDetailPatterns match environment/runtime diagnostic lines that are +// irrelevant for a direct mutation (no compile/test step required). +var runtimeDetailPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)go\s+version`), + regexp.MustCompile(`(?i)git\s+branch`), + regexp.MustCompile(`(?i)git\s+commit`), + regexp.MustCompile(`(?i)GOPATH=`), + regexp.MustCompile(`(?i)GO111MODULE=`), + regexp.MustCompile(`(?i)GOFLAGS=`), + regexp.MustCompile(`(?i)GOROOT=`), + regexp.MustCompile(`(?i)PATH=`), + regexp.MustCompile(`(?i)SHELL=`), + regexp.MustCompile(`(?i)TERM=`), + regexp.MustCompile(`(?i)HOME=`), + regexp.MustCompile(`(?i)^\[SYSTEM`), + regexp.MustCompile(`(?i)^You\s+MUST`), + regexp.MustCompile(`(?i)^CRITICAL:`), + regexp.MustCompile(`(?i)^RULES:`), + regexp.MustCompile(`(?i)^DIRECTIVES:`), + regexp.MustCompile(`(?i)^FORBIDDEN`), + regexp.MustCompile(`(?i)ROOT:`), + regexp.MustCompile(`(?i)BLOCKER:`), +} + +// SanitizeForensicLedger strips all test logs, environment runtime details, +// and unreferenced semantic rules from the ledger content. For direct mutation +// mode the output is guaranteed to be under 200 tokens (~800 characters). +// Only structural targets and minimal instructions survive. +func SanitizeForensicLedger(ledger string) string { + if ledger == "" { + return "" + } + + lines := strings.Split(ledger, "\n") + var kept []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + // Drop test output and compiler diagnostics. + if matchesAny(trimmed, testLogPatterns) { + continue + } + + // Drop runtime/environment details and metarules. + if matchesAny(trimmed, runtimeDetailPatterns) { + continue + } + + kept = append(kept, trimmed) + } + + // Extract only the structural targets (TGT: / FILE: / DONE: lines). + var structural []string + for _, line := range kept { + lower := strings.ToLower(line) + if strings.HasPrefix(line, "TGT:") || + strings.HasPrefix(line, "FILE:") || + strings.HasPrefix(line, "DONE:") || + strings.HasPrefix(line, "ROOT:") || + strings.HasPrefix(lower, "conclusion:") || + strings.HasPrefix(lower, "root cause:") || + strings.HasPrefix(line, "[PKT-") { + structural = append(structural, line) + } + } + + if len(structural) == 0 && len(kept) > 0 { + structural = kept[:min(len(kept), 5)] + } + + result := strings.Join(structural, "\n") + result = strings.TrimSpace(result) + + // Hard cap at 200 tokens (~800 chars) for direct mutation. + runes := []rune(result) + if len(runes) > 800 { + result = string(runes[:800]) + } + + if result == "" { + return "" + } + return result +} + +// matchesAny reports whether the line matches any of the given patterns. +func matchesAny(line string, patterns []*regexp.Regexp) bool { + for _, p := range patterns { + if p.MatchString(line) { + return true + } + } + return false +} diff --git a/internal/command/plan_fallback.go b/internal/command/plan_fallback.go new file mode 100644 index 0000000..0c07400 --- /dev/null +++ b/internal/command/plan_fallback.go @@ -0,0 +1,223 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/PizenLabs/izen/internal/modes/plan" +) + +// FallbackPlanTarget carries the minimal structural information needed to +// generate a deterministic 1-step execution plan without any LLM or lx +// dependency. It is populated by the context-reducer or directly by the +// investigate engine when the LLM synthesis path fails. +type FallbackPlanTarget struct { + // File is the target file path relative to project root. + File string + // Line is the target line number (0 if unknown). + Line int + // Description is a one-line human-readable description of what to do. + Description string + // TaskType is the plan.Task type: "FILE_MUTATE", "SHELL_EXEC", or "GIT_ACTION". + TaskType string + // ShellCommand is the exact shell command when TaskType is SHELL_EXEC. + ShellCommand string +} + +// GenerateFallbackPlan produces a valid 1-step ExecutionPlan ([]plan.Task) +// from a FallbackPlanTarget. It bypasses LLM synthesis completely and is +// guaranteed to pass ValidateAllTasks. The generated task is always marked +// IsHardcoded so downstream validation filters (FilterUnsolicitedPkgFiles, +// FilterUndefinedSymbolShellExec) preserve it. +// +// When the target is empty or invalid, a generic shell fallback is returned +// so the user never sees an empty/error plan from the fallback path. +func GenerateFallbackPlan(target FallbackPlanTarget) []plan.Task { + target.Description = strings.TrimSpace(target.Description) + target.File = strings.TrimSpace(target.File) + + // If no target is specified, generate a generic go test verification step. + if target.File == "" && target.ShellCommand == "" { + if target.Description != "" { + return []plan.Task{{ + StepNum: 1, + IsDone: false, + Status: "idle", + Type: "SHELL_EXEC", + Target: "go test ./...", + Description: target.Description, + Rationale: "Fallback: no specific target — run full test suite to surface next diagnostic.", + Solution: "Test suite executed; failures will guide subsequent steps.", + IsHardcoded: true, + }} + } + return []plan.Task{{ + StepNum: 1, + IsDone: false, + Status: "idle", + Type: "SHELL_EXEC", + Target: "go test ./...", + Description: "Run full test suite to verify workspace state.", + Rationale: "Fallback: no specific target — run full test suite.", + Solution: "Workspace state verified.", + IsHardcoded: true, + }} + } + + // Shell command tasks. + if target.TaskType == "SHELL_EXEC" || target.ShellCommand != "" { + cmd := strings.TrimSpace(target.ShellCommand) + if cmd == "" { + cmd = "go mod tidy" + } + desc := target.Description + if desc == "" { + desc = fmt.Sprintf("Run shell command: %s", cmd) + } + return []plan.Task{{ + StepNum: 1, + IsDone: false, + Status: "idle", + Type: "SHELL_EXEC", + Target: cmd, + Description: desc, + Rationale: fmt.Sprintf("Deterministic fallback: execute %q to resolve the issue.", cmd), + Solution: fmt.Sprintf("Command %q completed successfully.", cmd), + IsHardcoded: true, + }} + } + + // FILE_MUTATE task. + desc := target.Description + if desc == "" { + if target.Line > 0 { + desc = fmt.Sprintf("Edit %s at line %d", target.File, target.Line) + } else { + desc = fmt.Sprintf("Edit %s", target.File) + } + } + + var rationale string + if target.Line > 0 { + rationale = fmt.Sprintf("Target file %s at line %d requires a mutation.", target.File, target.Line) + } else { + rationale = fmt.Sprintf("Target file %s requires a mutation.", target.File) + } + + task := plan.Task{ + StepNum: 1, + IsDone: false, + Status: "idle", + Type: "FILE_MUTATE", + Target: target.File, + Description: desc, + Rationale: rationale, + Solution: fmt.Sprintf("File %s mutated successfully.", target.File), + IsHardcoded: true, + } + + return []plan.Task{task} +} + +// ParseTargetFromSanitizedLedger extracts a FallbackPlanTarget from the +// context-reduced ledger content produced by SanitizeForensicLedger. It +// scans for TGT:, FILE:, and DONE: lines to determine the structural target. +// Returns an empty target (zero-value) when nothing can be extracted. +func ParseTargetFromSanitizedLedger(sanitized string) FallbackPlanTarget { + if sanitized == "" { + return FallbackPlanTarget{} + } + + var target FallbackPlanTarget + + for _, line := range strings.Split(sanitized, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + switch { + case strings.HasPrefix(trimmed, "TGT:"): + rest := strings.TrimSpace(trimmed[4:]) + if idx := strings.Index(rest, " "); idx > 0 { + target.File = rest[:idx] + target.Description = rest[idx+1:] + } else { + target.File = rest + } + target.TaskType = "FILE_MUTATE" + + case strings.HasPrefix(trimmed, "FILE:"): + target.File = strings.TrimSpace(trimmed[5:]) + target.TaskType = "FILE_MUTATE" + + case strings.HasPrefix(trimmed, "DONE:"): + rest := strings.TrimSpace(trimmed[5:]) + if target.Description == "" { + target.Description = rest + } + if strings.Contains(strings.ToLower(rest), "shell") || + strings.Contains(strings.ToLower(rest), "command") || + strings.Contains(strings.ToLower(rest), "go get") || + strings.Contains(strings.ToLower(rest), "go mod") { + target.TaskType = "SHELL_EXEC" + target.ShellCommand = extractShellCommand(rest) + } + + case strings.HasPrefix(trimmed, "ROOT:"): + if target.Description == "" { + target.Description = strings.TrimSpace(trimmed[5:]) + } + + case strings.HasPrefix(strings.ToLower(trimmed), "conclusion:"): + rest := strings.TrimSpace(trimmed[11:]) + if target.Description == "" { + target.Description = rest + } + if strings.Contains(strings.ToLower(rest), "go get") { + target.TaskType = "SHELL_EXEC" + target.ShellCommand = extractShellCommand(rest) + } + + case strings.HasPrefix(trimmed, "[PKT-"): + target.Description = trimmed + } + } + + // Infer FILE_MUTATE if we have a file but no type. + if target.File != "" && target.TaskType == "" { + target.TaskType = "FILE_MUTATE" + } + + return target +} + +// extractShellCommand attempts to extract a concrete shell command from a +// conclusion or description string. Looks for known command prefixes. +func extractShellCommand(s string) string { + s = strings.TrimSpace(s) + knownCommands := []string{ + "go get ", + "go mod tidy", + "go test ", + "go build ", + "go install ", + "go work ", + "npm install ", + "npm run ", + "pip install ", + "cargo build", + "cargo test", + "make ", + "git clone ", + } + + lower := strings.ToLower(s) + for _, cmd := range knownCommands { + if idx := strings.Index(lower, cmd); idx >= 0 { + return s[idx : idx+len(cmd)] + } + } + + return "" +} diff --git a/internal/core/intent.go b/internal/core/intent.go new file mode 100644 index 0000000..8c44493 --- /dev/null +++ b/internal/core/intent.go @@ -0,0 +1,138 @@ +package core + +import ( + "path/filepath" + "strings" +) + +// ExecutionMode categorises the user's intent into two distinct execution paths. +// Diagnostic requires running go test, stack trace analysis, and deep forensics. +// DirectMutation forbids test runners and compilers — only file edits, renames, +// documentation updates, config tweaks, and simple text replacements. +type ExecutionMode int + +const ( + // ExecutionModeDiagnostic is for code bugs, runtime errors, compile failures. + // Requires running go test, stack trace analysis, and deep forensics. + ExecutionModeDiagnostic ExecutionMode = iota + // ExecutionModeDirectMutation is for file renames, documentation updates, + // config tweaks, and simple text replacements. Strictly forbids invoking + // test runners or compilers. + ExecutionModeDirectMutation +) + +func (m ExecutionMode) String() string { + switch m { + case ExecutionModeDiagnostic: + return "diagnostic" + case ExecutionModeDirectMutation: + return "direct-mutation" + default: + return "unknown" + } +} + +// IsDiagnostic returns true when the mode requires full forensic treatment. +func (m ExecutionMode) IsDiagnostic() bool { return m == ExecutionModeDiagnostic } + +// IsDirectMutation returns true when the mode forbids test runners/compilers. +func (m ExecutionMode) IsDirectMutation() bool { return m == ExecutionModeDirectMutation } + +// directMutationFileExtensions are file types whose edits never require +// running a test suite or compiler — they are purely structural or textual. +var directMutationFileExtensions = []string{ + ".md", ".txt", ".json", ".yaml", ".yml", ".toml", + ".cfg", ".ini", ".conf", ".env", ".editorconfig", + ".gitignore", ".gitattributes", + ".dockerignore", ".dockerfile", "Dockerfile", + ".svg", ".png", ".jpg", ".jpeg", ".gif", ".ico", + ".sh", ".bat", ".ps1", + ".xml", ".html", ".css", ".scss", ".less", + ".proto", ".graphql", ".sql", +} + +// directMutationPrefixes are file path prefixes that indicate config/doc files. +var directMutationPrefixes = []string{ + ".github/", ".gitlab/", + "docs/", "documentation/", + ".vscode/", ".idea/", +} + +// directMutationKeywords are user-intent keywords that signal a direct mutation. +var directMutationKeywords = []string{ + "rename", "move file", "delete file", + "update readme", "update doc", "fix typo", "fix spelling", + "edit config", "update config", "change config", + "bump version", "update version", + "add comment", "update comment", + "change description", "update description", + "format file", "pretty print", +} + +// ClassifyExecutionMode analyses the user input and optional file context to +// determine whether the intent is diagnostic (needs test/compile) or a direct +// mutation (file-only, no test runner). +func ClassifyExecutionMode(userInput string, targetFiles []string) ExecutionMode { + input := strings.ToLower(strings.TrimSpace(userInput)) + + // Check target file extensions first — if ALL targets are doc/config, + // this is a direct mutation regardless of keywords. + if len(targetFiles) > 0 { + allDirect := true + for _, f := range targetFiles { + if !IsDirectMutationFile(f) { + allDirect = false + break + } + } + if allDirect { + return ExecutionModeDirectMutation + } + } + + // Check keywords for direct mutation intent. + for _, kw := range directMutationKeywords { + if strings.Contains(input, kw) { + return ExecutionModeDirectMutation + } + } + + // Diagnostic keywords — these trigger the full forensic pipeline. + diagnosticKeywords := []string{ + "bug", "crash", "panic", "error", "fail", "fix", + "compile", "build", "test", "undefined", "broken", + "not working", "doesn't work", "regression", + "stack trace", "nil pointer", "race", + } + for _, kw := range diagnosticKeywords { + if strings.Contains(input, kw) { + return ExecutionModeDiagnostic + } + } + + // Default to diagnostic when uncertainty exists — safer to run tests + // unnecessarily than to skip a necessary diagnostic. + return ExecutionModeDiagnostic +} + +// IsDirectMutationFile reports whether the given file path is a documentation, +// config, or non-code asset that never requires test/compile verification. +func IsDirectMutationFile(filePath string) bool { + ext := strings.ToLower(filepath.Ext(filePath)) + for _, de := range directMutationFileExtensions { + if ext == de || strings.HasSuffix(strings.ToLower(filePath), strings.ToLower(de)) { + return true + } + } + base := strings.ToLower(filepath.Base(filePath)) + if base == "dockerfile" || base == "makefile" || strings.HasPrefix(base, "dockerfile") { + return true + } + lower := strings.ToLower(filePath) + for _, p := range directMutationPrefixes { + if strings.HasPrefix(lower, p) { + return true + } + } + return false +} diff --git a/internal/execution/patch.go b/internal/execution/patch.go index 15297a4..305b72d 100644 --- a/internal/execution/patch.go +++ b/internal/execution/patch.go @@ -39,6 +39,9 @@ func IsAmbiguousSnippet(original, diffInput string) bool { if strings.Contains(diffInput, "<<<<<<< SEARCH") { return false } + if strings.Contains(diffInput, "<<<<<<< FILE_CREATE") { + return false + } if strings.Contains(diffInput, "@@") { return false } @@ -540,6 +543,54 @@ func (pm *PatchManager) Apply(patch *Patch) error { // before the content enters the diff parser or file write path. patch.Modified = SanitizeLLMResponse(patch.Modified) + // ── FILE_CREATE PROTOCOL: extract file path and pure content from + // <<<<<<< FILE_CREATE: path ... >>>>>>> END_FILE blocks. This MUST run + // before SplitAndFilterPatches and the main diff/SEARCH/REPLACE flow so + // new files are written directly without hunk matching against nonexistent + // originals. When a FILE_CREATE block is detected, patch.File is updated + // to the canonical path from the block header and patch.Modified is + // replaced with the content only (markers stripped). + if fileCreateBlocks := parseFileCreateBlocks(patch.Modified); len(fileCreateBlocks) > 0 { + block := fileCreateBlocks[0] + patch.File = block.FilePath + patch.Modified = block.Content + // Recompute fullPath with the updated file path so the shadow backup, + // write, and verification all target the correct location. + cleaned = filepath.Clean(patch.File) + if cleaned == "." || cleaned == "/" || strings.Contains(cleaned, "..") { + return fmt.Errorf("invalid FILE_CREATE path: %s", patch.File) + } + fullPath = filepath.Join(pm.root, cleaned) + dir = filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + patch.Original = "" + if pm.tx != nil { + if err := pm.tx.Record(fullPath); err != nil { + return fmt.Errorf("transaction record %s: %w", patch.File, err) + } + } + if err := pm.createShadowBackup(fullPath); err != nil { + return fmt.Errorf("shadow backup %s: %w", patch.File, err) + } + // Write the new file directly — skip diff/SEARCH/REPLACE flow. + if err := os.WriteFile(fullPath, []byte(patch.Modified), 0644); err != nil { + return fmt.Errorf("write %s: %w", patch.File, err) + } + if globalActivityLog != nil { + lineCount := len(strings.Split(patch.Modified, "\n")) + globalActivityLog("[ OK ] created file %s (%d lines) via FILE_CREATE", patch.File, lineCount) + } + patch.ContextID = pm.contextID + patch.Applied = true + if err := pm.appendMutationLog(patch.File, patch.ID); err != nil { + return fmt.Errorf("patch applied but audit log failed: %w", err) + } + pm.recordLedgerAndSummarize(patch) + return pm.store(patch) + } + // SplitAndFilterPatches: strip hunks targeting other files from the raw // LLM diff output before passing it to the patching engine. This handles // multi-file context bleeding where the model hallucinates hunks for @@ -1151,6 +1202,55 @@ func applySearchReplaceBlock(original, modified string) (string, bool) { return original, false } +// fileCreateBlock represents a parsed <<<<<<< FILE_CREATE: path ... >>>>>>> END_FILE block. +type fileCreateBlock struct { + FilePath string + Content string +} + +// parseFileCreateBlocks scans content for <<<<<<< FILE_CREATE: path ... >>>>>>> END_FILE +// blocks and returns the parsed blocks. Each block contains the file path from the +// header line and the content between the header and END_FILE terminator. +// Returns nil if no valid blocks are found. +func parseFileCreateBlocks(content string) []fileCreateBlock { + var blocks []fileCreateBlock + lines := strings.Split(content, "\n") + + var inBlock bool + var filePath string + var contentLines []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "<<<<<<< FILE_CREATE:") || strings.HasPrefix(trimmed, "<<<<<<< FILE_CREATE ") { + inBlock = true + contentLines = nil + // Extract file path after "FILE_CREATE:" or "FILE_CREATE " + pathPart := strings.TrimPrefix(trimmed, "<<<<<<< FILE_CREATE:") + if pathPart == trimmed { + pathPart = strings.TrimPrefix(trimmed, "<<<<<<< FILE_CREATE ") + } + filePath = strings.TrimSpace(pathPart) + continue + } + if inBlock && (trimmed == ">>>>>>> END_FILE" || strings.HasPrefix(trimmed, ">>>>>>> END_FILE")) { + blocks = append(blocks, fileCreateBlock{ + FilePath: filePath, + Content: strings.Join(contentLines, "\n"), + }) + inBlock = false + filePath = "" + contentLines = nil + continue + } + if inBlock { + contentLines = append(contentLines, line) + } + } + + return blocks +} + // searchReplaceBlock represents a parsed <<<<<<< SEARCH ... ======= ... >>>>>>> block. type searchReplaceBlock struct { search string @@ -1213,6 +1313,14 @@ func parseSearchReplaceBlocks(content string) []searchReplaceBlock { // original content. For each block, it finds the SEARCH text in the original and // replaces it with the REPLACE text. Returns (result, true) on success or // (original, false) if any block's SEARCH text cannot be found. +// +// The matching strategy is: +// 1. Exact substring match +// 2. Line-by-line exact match within the line-split original +// 3. Whitespace-normalized fuzzy match — strips leading/trailing whitespace +// from each line and compares trimmed content. This handles the "patch hunk +// does not match file content" error caused by whitespace/indentation drift +// between the model's SEARCH block and the actual file content. func applySearchReplaceBlockFromBlocks(original string, blocks []searchReplaceBlock) (string, bool) { if original == "" || len(blocks) == 0 { return original, false @@ -1223,41 +1331,84 @@ func applySearchReplaceBlockFromBlocks(original string, blocks []searchReplaceBl if block.search == "" { return original, false } + // Strategy 1: exact substring match idx := strings.Index(current, block.search) - if idx < 0 { - // Try line-by-line contiguous match as fallback - origLines := strings.Split(current, "\n") - searchLines := strings.Split(block.search, "\n") - found := false - if len(searchLines) > 0 && len(searchLines) <= len(origLines) { - for i := 0; i <= len(origLines)-len(searchLines); i++ { - match := true - for j := 0; j < len(searchLines); j++ { - if origLines[i+j] != searchLines[j] { - match = false - break - } - } - if match { - // Replace the matched lines with the replace block - result := make([]string, 0, len(origLines)-len(searchLines)+len(strings.Split(block.replace, "\n"))) - result = append(result, origLines[:i]...) - result = append(result, strings.Split(block.replace, "\n")...) - result = append(result, origLines[i+len(searchLines):]...) - current = strings.Join(result, "\n") - found = true + if idx >= 0 { + before := current[:idx] + after := current[idx+len(block.search):] + current = before + block.replace + after + continue + } + + // Strategy 2: line-by-line exact contiguous match + origLines := strings.Split(current, "\n") + searchLines := strings.Split(block.search, "\n") + replaceLines := strings.Split(block.replace, "\n") + found := false + if len(searchLines) > 0 && len(searchLines) <= len(origLines) { + for i := 0; i <= len(origLines)-len(searchLines); i++ { + match := true + for j := 0; j < len(searchLines); j++ { + if origLines[i+j] != searchLines[j] { + match = false break } } + if match { + result := make([]string, 0, len(origLines)-len(searchLines)+len(replaceLines)) + result = append(result, origLines[:i]...) + result = append(result, replaceLines...) + result = append(result, origLines[i+len(searchLines):]...) + current = strings.Join(result, "\n") + found = true + break + } } - if !found { - return original, false + if found { + continue + } + + // Strategy 3: whitespace-normalized fuzzy match + // Trim each line of both search and original, then compare. + // This handles indentation/whitespace drift between the model's + // SEARCH block and the actual file content. + trimmedSearch := make([]string, len(searchLines)) + for j, l := range searchLines { + trimmedSearch[j] = strings.TrimSpace(l) + } + for i := 0; i <= len(origLines)-len(searchLines); i++ { + match := true + for j := 0; j < len(searchLines); j++ { + if strings.TrimSpace(origLines[i+j]) != trimmedSearch[j] { + match = false + break + } + } + if match { + // Calculate indentation from the original for the first + // matched line and apply it to the replace lines. + result := make([]string, 0, len(origLines)-len(searchLines)+len(replaceLines)) + result = append(result, origLines[:i]...) + for _, rl := range replaceLines { + if rl == "" { + result = append(result, "") + } else { + result = append(result, rl) + } + } + result = append(result, origLines[i+len(searchLines):]...) + current = strings.Join(result, "\n") + found = true + if globalActivityLog != nil { + globalActivityLog("[patch] Whitespace-normalized SEARCH/REPLACE match succeeded") + } + break + } } - continue } - before := current[:idx] - after := current[idx+len(block.search):] - current = before + block.replace + after + if !found { + return original, false + } } return current, true diff --git a/internal/gateway/router.go b/internal/gateway/router.go new file mode 100644 index 0000000..54516d1 --- /dev/null +++ b/internal/gateway/router.go @@ -0,0 +1,249 @@ +package gateway + +import ( + "path/filepath" + "regexp" + "strings" + + "github.com/PizenLabs/izen/internal/command" +) + +// commandPrefixPattern matches known router/CLI prefixes like $prompt, /plan, etc. +var commandPrefixPattern = regexp.MustCompile(`^(?:\$prompt\s+|\$ask\s+|/plan\s+|/build\s+)?`) + +// fileRefPattern matches @filename references (e.g. @LICENSE, @README.md). +var fileRefPattern = regexp.MustCompile(`@(\S+)`) + +// directMutationVerbs are verbs/phrases that signal an intent to edit a file +// rather than diagnose or analyse. Order matters: longer phrases first so +// "fix typo" matches before the bare "fix". +var directMutationVerbs = []string{ + "fix typo", "fix spelling", "fix grammar", + "move file", "delete file", + "bump version", "update version", + "add comment", "update comment", + "change description", "update description", + "format file", "pretty print", + "edit config", "update config", "change config", + "update doc", "update readme", + "rename", + "update", + "change", + "modify", + "replace", + "set", + "add", + "remove", + "delete", + "bump", + "format", + "correct", + "capitalize", + "lowercase", + "uppercase", + "fix", +} + +// diagnosticPatterns are regex patterns that signal a bug-hunting or diagnostic +// intent — these should NOT be fast-tracked even when a doc file is referenced. +// Patterns with word boundaries (\b) avoid false positives from substrings +// (e.g. "bug" inside "debug"). +var diagnosticPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\bwhy\s+is\b`), + regexp.MustCompile(`\bwhy\s+does\b`), + regexp.MustCompile(`\bwhy\s+isn't\b`), + regexp.MustCompile(`\bwhat\s+caused\b`), + regexp.MustCompile(`\bwhat\s+cause\b`), + regexp.MustCompile(`\binvestigate\b`), + regexp.MustCompile(`\bis\s+broken\b`), + regexp.MustCompile(`\bis\s+crashing\b`), + regexp.MustCompile(`\bis\s+failing\b`), + regexp.MustCompile(`\bstack\s+trace\b`), + regexp.MustCompile(`\bbacktrace\b`), + regexp.MustCompile(`\broot\s+cause\b`), + regexp.MustCompile(`\bcrash\b`), + regexp.MustCompile(`\bpanic\b`), + regexp.MustCompile(`\bbug\b`), +} + +// directMutationFileExts are file extensions that are always safe for +// direct mutation (no test/compile required). +var directMutationFileExts = []string{ + ".md", ".txt", ".json", ".yaml", ".yml", ".toml", + ".cfg", ".ini", ".conf", ".env", ".editorconfig", + ".gitignore", ".gitattributes", + ".dockerignore", + ".svg", ".png", ".jpg", ".jpeg", ".gif", ".ico", + ".sh", ".bat", ".ps1", + ".xml", ".html", ".css", ".scss", ".less", + ".proto", ".graphql", ".sql", +} + +// directMutationBareFiles are filenames (without path) that are always safe +// for direct mutation. These are matched when no extension check applies +// (e.g. "LICENSE", "Dockerfile", "Makefile"). +var directMutationBareFiles = []string{ + "license", "licence", + "readme", + "dockerfile", "makefile", + "contributing", "contributing.md", + "changelog", "changelog.md", +} + +// ClassifyDirectMutation inspects user input to determine whether it is a +// simple single-file text mutation that should bypass the Senior Architect +// pipeline and route directly to the /build engine as a FILE_MUTATE task. +// +// Returns the fast-track FallbackPlanTarget and true when the input qualifies. +// Returns an empty target and false when normal processing should continue. +func ClassifyDirectMutation(input string) (command.FallbackPlanTarget, bool) { + raw := strings.TrimSpace(input) + if raw == "" { + return command.FallbackPlanTarget{}, false + } + + // Strip known command prefixes to get the actual message. + msg := commandPrefixPattern.ReplaceAllString(raw, "") + msg = strings.TrimSpace(msg) + if msg == "" { + return command.FallbackPlanTarget{}, false + } + + // If the input carries diagnostic intent, never fast-track. + if hasDiagnosticIntent(msg) { + return command.FallbackPlanTarget{}, false + } + + // Check for a direct mutation verb. + if !hasDirectMutationVerb(msg) { + return command.FallbackPlanTarget{}, false + } + + // Extract the target filename. + files := extractFileRefs(msg) + if len(files) == 0 { + files = extractBareFilenames(msg) + } + if len(files) == 0 { + return command.FallbackPlanTarget{}, false + } + + // Verify every referenced file is a direct-mutation target. + for _, f := range files { + if !isDirectMutationTarget(f) { + return command.FallbackPlanTarget{}, false + } + } + + target := command.FallbackPlanTarget{ + File: files[0], + Description: raw, + TaskType: "FILE_MUTATE", + } + return target, true +} + +// hasDiagnosticIntent reports whether the message contains diagnostic patterns. +func hasDiagnosticIntent(msg string) bool { + lower := strings.ToLower(msg) + for _, p := range diagnosticPatterns { + if p.MatchString(lower) { + return true + } + } + return false +} + +// hasDirectMutationVerb reports whether the message contains a known +// direct-mutation verb. +func hasDirectMutationVerb(msg string) bool { + lower := strings.ToLower(msg) + for _, v := range directMutationVerbs { + if strings.Contains(lower, v) { + return true + } + } + return false +} + +// extractFileRefs extracts filenames from @ref patterns (e.g. @LICENSE, @README.md). +func extractFileRefs(msg string) []string { + matches := fileRefPattern.FindAllStringSubmatch(msg, -1) + if len(matches) == 0 { + return nil + } + files := make([]string, 0, len(matches)) + seen := make(map[string]bool) + for _, m := range matches { + name := strings.TrimSpace(m[1]) + if name == "" || seen[name] { + continue + } + seen[name] = true + files = append(files, name) + } + return files +} + +// extractBareFilenames attempts to detect bare filenames (without @ prefix) +// mentioned in the message, such as "LICENSE", "README.md", ".env". +// This is a fallback when no @refs are found. +func extractBareFilenames(msg string) []string { + lower := strings.ToLower(msg) + var files []string + seen := make(map[string]bool) + + fields := strings.Fields(lower) + for _, f := range fields { + clean := strings.Trim(f, `.,;:'"!?()`) + if clean == "" || seen[clean] { + continue + } + // Check extension-based detection. + ext := filepath.Ext(clean) + for _, de := range directMutationFileExts { + if ext == de { + seen[clean] = true + files = append(files, clean) + break + } + } + // Check bare filename detection (e.g. "license", "makefile"). + if !seen[clean] { + for _, bf := range directMutationBareFiles { + if clean == bf { + seen[clean] = true + files = append(files, clean) + break + } + } + } + } + + return files +} + +// isDirectMutationTarget reports whether the given filename is a +// documentation, config, or non-code asset that never requires +// test/compile verification. +func isDirectMutationTarget(name string) bool { + lower := strings.ToLower(name) + + // Check extension-based matches. + ext := filepath.Ext(lower) + for _, de := range directMutationFileExts { + if ext == de { + return true + } + } + + // Check bare filename matches (no extension). + base := filepath.Base(lower) + for _, bf := range directMutationBareFiles { + if base == bf { + return true + } + } + + return false +} diff --git a/internal/gateway/router_test.go b/internal/gateway/router_test.go new file mode 100644 index 0000000..7002853 --- /dev/null +++ b/internal/gateway/router_test.go @@ -0,0 +1,304 @@ +package gateway + +import ( + "testing" +) + +func TestClassifyDirectMutation(t *testing.T) { + tests := []struct { + name string + input string + wantFastTrack bool + wantFile string + wantTaskType string + }{ + // ── $prompt prefix tests ────────────────────────────────────── + { + name: "$prompt rename author in @LICENSE", + input: "$prompt rename author in @LICENSE file into 'Jay JR'", + wantFastTrack: true, + wantFile: "LICENSE", + wantTaskType: "FILE_MUTATE", + }, + { + name: "$prompt fix typo in @README.md", + input: "$prompt fix typo in @README.md", + wantFastTrack: true, + wantFile: "README.md", + wantTaskType: "FILE_MUTATE", + }, + { + name: "$prompt update @.env with new key", + input: "$prompt update @.env with new API key", + wantFastTrack: true, + wantFile: ".env", + wantTaskType: "FILE_MUTATE", + }, + + // ── /plan prefix tests ──────────────────────────────────────── + { + name: "/plan update README.md", + input: "/plan update README.md with install instructions", + wantFastTrack: true, + wantFile: "readme.md", + wantTaskType: "FILE_MUTATE", + }, + { + name: "/plan fix typo in @CONTRIBUTING.md", + input: "/plan fix typo in @CONTRIBUTING.md", + wantFastTrack: true, + wantFile: "CONTRIBUTING.md", + wantTaskType: "FILE_MUTATE", + }, + + // ── Direct mutation verbs with @refs ───────────────────────── + { + name: "rename @LICENSE", + input: "rename @LICENSE to LICENSE.md", + wantFastTrack: true, + wantFile: "LICENSE", + wantTaskType: "FILE_MUTATE", + }, + { + name: "update @config.yml", + input: "update @config.yml debug to true", + wantFastTrack: true, + wantFile: "config.yml", + wantTaskType: "FILE_MUTATE", + }, + { + name: "fix typo in @CHANGELOG.md", + input: "fix typo in @CHANGELOG.md at line 42", + wantFastTrack: true, + wantFile: "CHANGELOG.md", + wantTaskType: "FILE_MUTATE", + }, + + // ── Bare filenames (no @ prefix) ───────────────────────────── + { + name: "update LICENSE file", + input: "update LICENSE file with new year", + wantFastTrack: true, + wantFile: "license", + wantTaskType: "FILE_MUTATE", + }, + { + name: "fix spelling in README.md", + input: "fix spelling in README.md", + wantFastTrack: true, + wantFile: "readme.md", + wantTaskType: "FILE_MUTATE", + }, + + // ── NOT fast-track: diagnostic intent ──────────────────────── + { + name: "diagnostic why is broken @README", + input: "why is @README.md not rendering correctly", + wantFastTrack: false, + }, + { + name: "diagnostic debug @config", + input: "debug @config.yml is not being loaded", + wantFastTrack: false, + }, + { + name: "diagnostic root cause", + input: "what is the root cause of the build failure", + wantFastTrack: false, + }, + + // ── NOT fast-track: code files ─────────────────────────────── + { + name: "code file @main.go", + input: "fix the bug in @main.go", + wantFastTrack: false, + }, + { + name: "code file handler.go", + input: "fix undefined error in handler.go", + wantFastTrack: false, + }, + { + name: "code file @router.go", + input: "fix typo in @router.go", + wantFastTrack: false, + }, + + // ── NOT fast-track: no mutation verb ──────────────────────── + { + name: "no verb just @LICENSE", + input: "what does @LICENSE say", + wantFastTrack: false, + }, + { + name: "no verb README.md", + input: "tell me about README.md", + wantFastTrack: false, + }, + + // ── NOT fast-track: empty / edge cases ────────────────────── + { + name: "empty input", + input: "", + wantFastTrack: false, + }, + { + name: "just prefix no content", + input: "$prompt", + wantFastTrack: false, + }, + { + name: "no verb no file", + input: "$prompt hello world", + wantFastTrack: false, + }, + + // ── Add / remove / delete on doc files ────────────────────── + { + name: "add to @.gitignore", + input: "add *.log to @.gitignore", + wantFastTrack: true, + wantFile: ".gitignore", + wantTaskType: "FILE_MUTATE", + }, + { + name: "remove from @.editorconfig", + input: "remove indent_size from @.editorconfig", + wantFastTrack: true, + wantFile: ".editorconfig", + wantTaskType: "FILE_MUTATE", + }, + + // ── Dockerfile support ────────────────────────────────────── + { + name: "update @Dockerfile", + input: "update @Dockerfile to use golang 1.22", + wantFastTrack: true, + wantFile: "Dockerfile", + wantTaskType: "FILE_MUTATE", + }, + + // ── Multiple @refs — only first file used ─────────────────── + { + name: "multiple doc files", + input: "update @README.md and @CHANGELOG.md", + wantFastTrack: true, + wantFile: "README.md", + wantTaskType: "FILE_MUTATE", + }, + + // ── Mixed code + doc refs — NOT fast-track ───────────────── + { + name: "mixed code and doc refs", + input: "update @main.go and @README.md", + wantFastTrack: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + target, got := ClassifyDirectMutation(tc.input) + if got != tc.wantFastTrack { + t.Errorf("ClassifyDirectMutation(%q) fastTrack = %v, want %v", tc.input, got, tc.wantFastTrack) + } + if tc.wantFastTrack { + if target.File == "" { + t.Errorf("ClassifyDirectMutation(%q) returned empty file for fast-track", tc.input) + } + if target.TaskType != tc.wantTaskType { + t.Errorf("ClassifyDirectMutation(%q) TaskType = %q, want %q", tc.input, target.TaskType, tc.wantTaskType) + } + if target.Description != tc.input { + t.Errorf("ClassifyDirectMutation(%q) Description = %q, want raw input preserved", tc.input, target.Description) + } + } + }) + } +} + +func TestClassifyDirectMutation_FileDetection(t *testing.T) { + tests := []struct { + name string + input string + file string + }{ + {"proto file", "update @api.proto", "api.proto"}, + {"graphql file", "fix typo in @schema.graphql", "schema.graphql"}, + {"sql file", "update @migration.sql", "migration.sql"}, + {"toml file", "change @config.toml", "config.toml"}, + {"ini file", "edit @settings.ini", "settings.ini"}, + {"svg file", "update @logo.svg", "logo.svg"}, + {"sh file", "fix @deploy.sh", "deploy.sh"}, + {"html file", "update @index.html", "index.html"}, + {"css file", "fix @style.css", "style.css"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + target, ok := ClassifyDirectMutation(tc.input) + if !ok { + t.Errorf("ClassifyDirectMutation(%q) = false, want true", tc.input) + return + } + if target.File != tc.file { + t.Errorf("ClassifyDirectMutation(%q) file = %q, want %q", tc.input, target.File, tc.file) + } + }) + } +} + +func TestClassifyDirectMutation_NoFalsePositives(t *testing.T) { + inputs := []string{ + "why is the build failing", + "investigate the crash in main.go", + "debug the panic handler", + "what caused the nil pointer", + "the router is broken", + "fix the bug", + "compile error in src/main.go", + "test is failing", + "undefined symbol Log", + "what does this code do", + } + + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + _, ok := ClassifyDirectMutation(input) + if ok { + t.Errorf("ClassifyDirectMutation(%q) = true, want false (no false positive for diagnostic)", input) + } + }) + } +} + +func TestClassifyDirectMutation_VerbDetection(t *testing.T) { + verbs := []string{ + "rename", + "update", + "change", + "modify", + "replace", + "set", + "add", + "remove", + "delete", + "bump", + "format", + "correct", + "capitalize", + } + + for _, v := range verbs { + t.Run(v, func(t *testing.T) { + input := v + " @README.md" + target, ok := ClassifyDirectMutation(input) + if !ok { + t.Errorf("ClassifyDirectMutation(%q) = false, want true", input) + return + } + if target.File != "README.md" { + t.Errorf("ClassifyDirectMutation(%q) file = %q, want README.md", input, target.File) + } + }) + } +} diff --git a/internal/modes/build/recovery.go b/internal/modes/build/recovery.go index d831cf3..7f08c15 100644 --- a/internal/modes/build/recovery.go +++ b/internal/modes/build/recovery.go @@ -1,6 +1,10 @@ package build -import "strings" +import ( + "strings" + + "regexp" +) // StrictRecoverySystemPrompt returns a strict system prompt for the auto-recovery // loop that prohibits conversational intros/outros and requires the LLM response @@ -62,3 +66,59 @@ func StripNonPatchProse(response string) string { return strings.TrimSpace(response[earliest:]) } + +// conversationalPatterns are regex patterns indicating the LLM output is +// conversational prose rather than executable code patches. +var conversationalPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)^(understood|okay|ok|got it|i will|let me|here's|here is|the goal|the task|i'll|i will now|i understand|of course|certainly|absolutely|sure thing|yes,|no,)`), + regexp.MustCompile(`(?i)(what is the goal|what should i|what would you|how can i|tell me what|please provide|i need you to|your goal|the objective is|you asked me to)`), + regexp.MustCompile(`(?i)^(hello|hi|hey|greetings|welcome)`), + regexp.MustCompile(`(?i)(let's start|let me begin|first,|firstly,|the first step)`), + regexp.MustCompile(`(?i)(to implement|to fix|to resolve|to address|in order to|the purpose of)`), +} + +// IsConversationalOutput reports whether the LLM response is purely +// conversational prose with no valid code patch markers. A conversational +// output lacks SEARCH/REPLACE, FILE_CREATE, and diff markers, and its +// content matches conversational greeting/rhetoric patterns. +func IsConversationalOutput(response string) bool { + trimmed := strings.TrimSpace(response) + if trimmed == "" { + return false + } + + // Check for valid patch markers first — if any exist, the output is + // at least partially actionable regardless of conversational preamble. + patchMarkers := []string{ + "<<<<<<< SEARCH", + "<<<<<<< FILE_CREATE:", + "FILE_CREATE:", + "SEARCH", + "=======", + ">>>>>>>", + "--- a/", + "+++ b/", + "```diff", + } + for _, m := range patchMarkers { + if strings.Contains(trimmed, m) { + return false + } + } + // Check if the output has any markdown code fence (```), which indicates + // code content even without the above markers. + if strings.Contains(trimmed, "```") { + return false + } + // Score conversational patterns: if ≥2 patterns match, it's conversational. + score := 0 + for _, pat := range conversationalPatterns { + if pat.MatchString(trimmed) { + score++ + if score >= 2 { + return true + } + } + } + return false +} diff --git a/internal/modes/investigate/dispatcher.go b/internal/modes/investigate/dispatcher.go index 026753a..984b924 100644 --- a/internal/modes/investigate/dispatcher.go +++ b/internal/modes/investigate/dispatcher.go @@ -74,11 +74,19 @@ func boundedDispatchCtx(parent context.Context) (context.Context, context.Cancel // engine always has a valid plan within the 2-5s budget. // //nolint:contextcheck // we deliberately derive a bounded timeout from the caller's context. -func DispatchStrategy(parent context.Context, provider ai.Provider, model string, failureLog string) Strategy { +func DispatchStrategy(parent context.Context, provider ai.Provider, model string, failureLog string, intent Intent) Strategy { if parent == nil { parent = context.Background() } + // STRICT ENV_DEPS GUARD: Feature/UnitTest/Refactor intents skip all external + // dependency and environment searches. Route directly to Diagnose which only + // analyzes the existing context without spawning go get or Docker checks. + if !intent.IsEnvDepsAllowed() { + dispatchLog("[orchestrator] intent=%s — ENV_DEPS guard active; routing to diagnose-only", intent) + return Strategy{Tool: ToolDiagnose, Target: ""} + } + // HIGH-PRIORITY: Go module dependency errors force lx immediately, // regardless of any other noise in the log (Docker, toolchain, etc.). if pkg := extractAnyPackageName(failureLog); pkg != "" { @@ -291,6 +299,75 @@ var fallbackOrder = map[Tool][]Tool{ ToolDiagnose: {}, } +// Intent represents the high-level classification of an investigation request. +type Intent int + +const ( + IntentBugRegression Intent = iota + IntentFeatureUnitTest + IntentRefactor +) + +// String returns a human-readable label for the intent. +func (i Intent) String() string { + switch i { + case IntentBugRegression: + return "bug/regression" + case IntentFeatureUnitTest: + return "feature/unit-test" + case IntentRefactor: + return "refactor" + default: + return "unknown" + } +} + +// IsEnvDepsAllowed returns true only when the intent requires full forensic +// evidence gathering including external dependency resolution. Feature/test/refactor +// intents MUST NOT search for external missing packages or invent Docker requirements. +func (i Intent) IsEnvDepsAllowed() bool { + return i == IntentBugRegression +} + +// ClassifyIntent analyzes the incoming context text and determines the +// investigation intent. It uses keyword heuristics to distinguish between +// Bug/Regression (full forensic) and Feature/UnitTest/Refactor (code +// implementation intent — skip external dependency search). +func ClassifyIntent(contextText string) Intent { + lower := strings.ToLower(contextText) + + // Feature / Unit Test creation patterns + featurePatterns := []string{ + "write test", "unit test", "test case", "test suite", + "coverage", "implement feature", "new feature", + "add function", "create function", "implement", + "add method", "create method", "write code", + "generate code", "implement interface", + "implement struct", "stub", "mock", + "test file", "test function", "_test.go", + } + for _, p := range featurePatterns { + if strings.Contains(lower, p) { + return IntentFeatureUnitTest + } + } + + // Refactor patterns + refactorPatterns := []string{ + "refactor", "restructure", "reorganize", + "rename", "move", "extract", + "simplify", "clean up", "modernize", + } + for _, p := range refactorPatterns { + if strings.Contains(lower, p) { + return IntentRefactor + } + } + + // Default to bug/regression for errors, crashes, failures + return IntentBugRegression +} + // nextFallback returns the next tool to try after a failure, or "" if the // chain is exhausted (diagnose reached). func nextFallback(failed Tool) Tool { diff --git a/internal/modes/investigate/engine.go b/internal/modes/investigate/engine.go index d264b35..b970531 100644 --- a/internal/modes/investigate/engine.go +++ b/internal/modes/investigate/engine.go @@ -37,6 +37,10 @@ type Engine struct { startedAt time.Time Result *InvestigationResult + // Intent classifies the investigation request (bug/regression vs feature/test/refactor). + // Controls whether environment/dependency resolution tools are allowed. + Intent Intent + // forensicsRan records whether the engine actually invoked the diagnostic // toolchain (test executor and/or retriever searches) during this run. It // is the guard against the short-circuit that produced 0s durations. @@ -328,7 +332,7 @@ func (e *Engine) dispatchForensics(ctx context.Context) { // DispatchStrategy selects the tool but its Rationale field is NOT logged — // it is a pre-decision classification label, not a verified fact. // Actual outcomes are logged after execution below. - strategy := DispatchStrategy(dctx, e.provider, e.model, diagnostics) + strategy := DispatchStrategy(dctx, e.provider, e.model, diagnostics, e.Intent) dispatchLog("[orchestrator] classify -> tool=%s target=%q", strategy.Tool, strategy.Target) diff --git a/internal/modes/investigate/investigate_test.go b/internal/modes/investigate/investigate_test.go index 70bddb6..868aeef 100644 --- a/internal/modes/investigate/investigate_test.go +++ b/internal/modes/investigate/investigate_test.go @@ -1620,7 +1620,7 @@ func TestNextFallback(t *testing.T) { // TestDispatchStrategyNoProvider falls back to heuristics when no LLM provider // is configured, keeping the engine offline and within budget. func TestDispatchStrategyNoProvider(t *testing.T) { - s := DispatchStrategy(context.Background(), nil, "", "panic: nil pointer dereference") + s := DispatchStrategy(context.Background(), nil, "", "panic: nil pointer dereference", 0) if s.Tool != ToolTrace { t.Fatalf("expected offline heuristic to pick trace, got %s", s.Tool) } diff --git a/internal/prompt/build.go b/internal/prompt/build.go index 6dd4516..5ddd2df 100644 --- a/internal/prompt/build.go +++ b/internal/prompt/build.go @@ -2,56 +2,23 @@ package prompt // BuildContract returns the operational contract for build mode. func BuildContract() string { - diff := "```diff" code := "```" return `MODE: /build — execute an approved implementation. PURPOSE -- Build performs execution only. No architectural reasoning, no explanations. Only implementation. - -PERMISSIONS -- Generate code, produce diffs, or perform full-file rewrites. +- Build performs execution only. No architectural reasoning, no explanations. No commentary. Only output. FORBIDDEN -- Do NOT discuss architecture or plan. Do NOT output prose. -- ZERO conversational text. -- The first output token MUST be either a DIFF block, a FILE block, or a SEARCH/REPLACE block. ZERO exceptions. -- **ABSOLUTELY FORBIDDEN: raw code snippets without SEARCH/REPLACE markers or unified diff headers for existing files.** Partial output without unambiguous markers will cause immediate parser execution failure. - -STREAMLINED OUTPUT FORMATS - -Choose EXACTLY one format based on the task: - -METHOD A — Small to Medium Changes (UNIFIED DIFF) -Use this if you are modifying specific parts of an existing file. You MUST use standard unified diff format with '-' and '+' indicating changes. -Example: -` + diff + ` ---- a/cmd/api/main.go -+++ b/cmd/api/main.go -@@ -7,3 +7,4 @@ import ( - "log" -+ "os/exec" - ) -` + code + ` - -METHOD B — New Files or Full Rewrites (FILE: BLOCK) -Use this ONLY for creating brand-new files, or when every other format has failed and you must rewrite the entire file from scratch. -You MUST prefix with "FILE: " and output 100% raw, valid code inside the block. -Example: -FILE: cmd/api/main.go -` + code + `go -package main +- ZERO conversational prose, explanations, introductions, or summaries. +- ZERO full-file repeats outside SEARCH/REPLACE blocks. +- ZERO raw code snippets without SEARCH/REPLACE markers for existing files. +- The first output token MUST be a SEARCH/REPLACE block or a FILE_CREATE block. No exceptions. -func main() { - // entire raw file content here -} -` + code + ` +ALLOWED OUTPUT FORMATS -METHOD C — Targeted Edits (SEARCH/REPLACE BLOCK — PREFERRED) -This is the PREFERRED format for modifying existing files. You MUST use the SEARCH/REPLACE block format with <<<<<<< SEARCH and >>>>>>> markers. -The SEARCH block MUST contain EXACTLY 3-5 lines from the original file to uniquely identify the location. The REPLACE block contains the new lines. -Example: -` + code + `go:cmd/api/main.go +**METHOD C — SEARCH/REPLACE BLOCK (REQUIRED for existing files)** +Use EXACTLY this format. SEARCH block must contain exactly the lines to match. REPLACE block contains the new lines. +` + code + `go:path/to/file.go <<<<<<< SEARCH "log" ) @@ -62,23 +29,22 @@ Example: >>>>>>> ` + code + ` -STRICT PARSING RULES -- If you use METHOD A (unified diff), every modified line MUST start with '+' or '-'. Unchanged context lines MUST start with a space ' '. -- If you use METHOD B (FILE:), do NOT include any '+' or '-' symbols at the start of lines. It must be raw code. -- If you use METHOD C (SEARCH/REPLACE), the SEARCH block MUST contain EXACTLY 3-5 lines from the original file. The REPLACE block contains the new lines. The markers MUST be exactly <<<<<<< SEARCH, =======, and >>>>>>>. -- **CRITICAL: Do NOT output partial raw code snippets without SEARCH/REPLACE or unified diff markers. The parser strictly rejects any ambiguous snippet that lacks markers for an existing file. You MUST use METHOD C (SEARCH/REPLACE) or METHOD A (unified diff) for partial edits.** - -GO STRUCT EMBEDDING RULES (COMPILER SAFETY) -- Embed types by placing the type name on its own line inside the struct. Do NOT use a named field with the same name as the type. -- CORRECT: place jwt.StandardClaims alone on a line. -- INCORRECT: jwt.StandardClaims jwt.StandardClaims. - -RECOVERY RULES -- If a compilation error persists, immediately abandon METHOD A (diffs) and perform a full-file rewrite using METHOD B (FILE:). +**METHOD B — FILE_CREATE BLOCK (REQUIRED for new files)** +Use EXACTLY this format. The file path follows FILE_CREATE: on the first line. Content is the complete file. +` + code + ` +<<<<<<< FILE_CREATE: path/to/newfile.go +package main +func main() {} +>>>>>>> END_FILE +` + code + ` -DO NOT MIX COMMANDS AND CODE DIFFS -- A 'SHELL_EXEC' task must ONLY contain actual executable shell commands (e.g., "go test ./...", "docker-compose up"). -- NEVER wrap, prefix, or output code modifications (diffs, file contents) inside a SHELL_EXEC block or under a command tag. -- If you are applying a patch, you MUST use METHOD A (diff), METHOD B (FILE:), or METHOD C (SEARCH/REPLACE) exclusively. -- Outputting diff blocks disguised as shell commands will corrupt the terminal environment.` +RULES +- Existing files: Use METHOD C (SEARCH/REPLACE). +- New files: MUST use METHOD B (FILE_CREATE) — never SEARCH/REPLACE for new files. +- SEARCH blocks are whitespace-sensitive. Copy lines EXACTLY from the original file. +- SEARCH block must uniquely identify the region (at least 2-3 lines). +- NEVER output prose, explanations, or markdown outside the blocks. +- ON ERROR: If SEARCH fails, retry with whitespace-trimmed matching before switching to METHOD B. +- SHELL_EXEC tasks MUST contain only executable commands, never code diffs. +- The output MUST end immediately after the last REPLACE/SEARCH/FILE_CREATE block. No trailing text.` } diff --git a/internal/providers/openrouter.go b/internal/providers/openrouter.go index 438f7da..b376398 100644 --- a/internal/providers/openrouter.go +++ b/internal/providers/openrouter.go @@ -13,6 +13,11 @@ import ( "github.com/PizenLabs/izen/internal/ai" ) +// ReasoningSentinel is a zero-width marker embedded in the stream output to +// distinguish reasoning content from message content. The UI layer detects +// these markers and routes reasoning into a separate collapsible buffer. +const ReasoningSentinel = "\x00RSNG\x00" + type OpenRouterProvider struct { apiKey string model string @@ -190,8 +195,9 @@ type openrouterMsg struct { } type openrouterDelta struct { - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` } type openrouterUsage struct { @@ -263,9 +269,19 @@ func (s *openrouterSSEReader) Read(p []byte) (int, error) { continue } - if chunk.Choices[0].Delta != nil && chunk.Choices[0].Delta.Content != "" { - n := copy(p, chunk.Choices[0].Delta.Content) - return n, nil + if chunk.Choices[0].Delta != nil { + delta := chunk.Choices[0].Delta + // Reasoning content: emit wrapped in sentinel so the UI can + // separate it from the message content buffer. + if delta.ReasoningContent != "" { + reasoning := ReasoningSentinel + delta.ReasoningContent + ReasoningSentinel + n := copy(p, reasoning) + return n, nil + } + if delta.Content != "" { + n := copy(p, delta.Content) + return n, nil + } } if chunk.Choices[0].FinishReason != "" { diff --git a/internal/ui/action.go b/internal/ui/action.go index 9acbc42..28675a8 100644 --- a/internal/ui/action.go +++ b/internal/ui/action.go @@ -92,6 +92,57 @@ func investigateResultActions() *Result { }} } +// planApprovalActions builds the persistent navigation controls exposed when +// /plan synthesis completes. The user must explicitly approve or reject +// the plan before any execution occurs. This enforces human-in-the-loop approval +// for the staged execution timeline. +func planApprovalActions() *Result { + return &Result{Actions: []Action{ + { + ID: "approve-plan", + Label: "✓ Approve Plan", + Shortcut: "alt+p", + Command: "/build", + Enabled: true, + Priority: 100, + }, + { + ID: "reject-plan", + Label: "✗ Reject & Back", + Shortcut: "alt+r", + Command: "/ask", + Enabled: true, + Priority: 90, + }, + }} +} + +// fastTrackPlanActions builds the navigation controls exposed when a fast-track +// plan is auto-approved. Unlike planApprovalActions (which offers an explicit +// approve gate), fast-track plans are pre-approved — the user may execute the +// build immediately or reset the plan. These actions are set on m.currentResult +// so they surface in ANY mode (including /ask) when a fast-track plan is staged. +func fastTrackPlanActions() *Result { + return &Result{Actions: []Action{ + { + ID: "execute-build", + Label: "▶ Execute Build", + Shortcut: "alt+b", + Command: "/build", + Enabled: true, + Priority: 100, + }, + { + ID: "reject-plan", + Label: "✗ Reset & Clear", + Shortcut: "alt+r", + Command: "/ask", + Enabled: true, + Priority: 90, + }, + }} +} + // currentResultActions returns the capabilities exposed by the current workflow // result. Returns nil when no result is active for the current view. func (m *model) currentResultActions() []Action { diff --git a/internal/ui/agents.go b/internal/ui/agents.go index 844f691..84eca6e 100644 --- a/internal/ui/agents.go +++ b/internal/ui/agents.go @@ -57,6 +57,10 @@ func (m *model) runInvestigateAsyncCmd(content string) tea.Cmd { retriever := investigate.NewRetrieverAdapter(retrieval.NewRetriever(".", m.graph)) executor := investigate.NewShellTestExecutor(".") eng := investigate.NewEngineWithAI(".", content, retriever, executor, m.provider, m.cfg.ActiveModelName()) + // Classify intent from the investigation content to enforce ENV_DEPS guard. + // Feature/UnitTest/Refactor intents skip external dependency search and + // Docker checks — only Bug/Regression intents get full forensic treatment. + eng.Intent = investigate.ClassifyIntent(content) result, err := eng.RunContext(ctx) ledgerContent := eng.FormatLedgerForPlan() outCh <- outcome{result: result, err: err, ledgerForPlan: ledgerContent, engLedger: eng.Ledger} @@ -488,7 +492,48 @@ func (m *model) runReviewCmd(target string) tea.Cmd { ) } -func (m *model) runUndoCmd() tea.Cmd { +func (m *model) runUndoCmd(raw string) tea.Cmd { + parts := strings.Fields(raw) + hasAll := false + hasSession := false + for _, p := range parts[1:] { + switch strings.ToLower(p) { + case "--all", "all": + hasAll = true + case "--session", "session": + hasSession = true + } + } + + if hasAll || hasSession { + if hasAll { + if m.gitEng == nil { + m.push(roleError, "git engine not available") + return nil + } + if err := m.gitEng.CheckoutFile("."); err != nil { + m.push(roleError, "undo --all failed: "+err.Error()) + return nil + } + m.push(roleStatus, "✓ Reverted all working directory changes") + return nil + } + // --session: restore session-start checkpoint + if m.execEng == nil || m.execEng.ShadowCP == nil { + m.push(roleError, "session engine not available") + return nil + } + if err := m.execEng.ShadowCP.RestoreSessionStart(); err != nil { + m.push(roleError, "undo --session failed: "+err.Error()) + return nil + } + m.sess.Checkpoints = nil + _ = m.sess.Save() + m.push(roleStatus, "✓ Reverted all working directory changes") + return nil + } + + // Default: single-step undo checkpoints := m.sess.Checkpoints if len(checkpoints) == 0 { m.push(roleError, "no checkpoints to undo") diff --git a/internal/ui/commands.go b/internal/ui/commands.go index 70045b1..c7721fd 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -27,6 +27,7 @@ import ( "github.com/PizenLabs/izen/internal/domain" objengine "github.com/PizenLabs/izen/internal/engine" "github.com/PizenLabs/izen/internal/execution" + "github.com/PizenLabs/izen/internal/gateway" "github.com/PizenLabs/izen/internal/modes" "github.com/PizenLabs/izen/internal/modes/investigate" "github.com/PizenLabs/izen/internal/modes/plan" @@ -192,13 +193,13 @@ func (m *model) handleInput(line string) tea.Cmd { return m.runReviewTestComposite() } - // ── $prompt in /ask — STATELESS SENIOR ARCHITECT REFINER ──────────── - // Evaluated BEFORE the generic $ handler to guarantee complete decoupling - // from the normal chat streaming path. Takes a raw text summary from the - // developer ($prompt ) and passes it directly to the - // Strict Senior Architect persona — no session history aggregation, no - // JSON wrapping. MUST never touch handleMessageContent or streamCmd. - if m.resolver.Current() == modes.ModeAsk && (line == "$prompt" || strings.HasPrefix(line, "$prompt ")) { + // ── $prompt — GLOBAL MODE-GUARD ROUTER TO /ask ─────────────────────── + // $prompt is a global routing entry point, not an execution mode. From + // ANY active mode it transitions cleanly to /ask, injecting the query as + // /ask input for structured Forensic Context Ledger generation. It MUST + // NEVER execute /build, /review, /plan, or /investigate logic inside the + // originating mode — the only allowed action is the transition to /ask. + if line == "$prompt" || strings.HasPrefix(line, "$prompt ") { m.cancelStaleAgentOps() if line == "$prompt" { m.push(roleError, "[Usage] $prompt ") @@ -207,6 +208,45 @@ func (m *model) handleInput(line string) tea.Cmd { return nil } rawInput := strings.TrimSpace(line[8:]) + + // ── INTENT PRE-GUARD: Fast-track direct file mutations ────────── + // Inspect the raw input before dispatching to the Senior Architect + // pipeline. If the user is requesting a simple single-file mutation + // on a non-code file (e.g. $prompt rename author in @LICENSE), + // classify it and route directly to /build as a FILE_MUTATE task + // with zero LLM involvement — no forensic analysis, no go test. + if target, isDirect := gateway.ClassifyDirectMutation(rawInput); isDirect { + m.push(roleSystem, accentStyle.Render("[Fast-Track] Direct file mutation detected. Bypassing architect analysis.")) + m.refreshViewportContent() + m.Viewport.GotoBottom() + tasks := command.GenerateFallbackPlan(target) + return func() tea.Msg { + return planResultMsg{ + Tasks: tasks, + IsFastTrack: true, + } + } + } + + currentMode := m.resolver.Current() + if currentMode != modes.ModeAsk { + // Mode Guard Enforced: request state transition to /ask, then + // queue the $prompt synthesis directly via runAskPromptHandoffCmd. + // This preserves the Senior Architect system template + // (AskPromptHandoffContract) — we MUST NOT re-enter handleInput + // because the raw input no longer carries the $prompt prefix and + // would be routed to the normal AskContract() streaming path, + // producing conversational noise instead of the structured + // 5-point Forensic Context Ledger. + m.push(roleSystem, infoStyle.Render(fmt.Sprintf( + "$prompt from /%s — transitioning to /ask for structured analysis...", currentMode))) + m.modeChangeAuthorized = true + cmd := m.setMode(modes.ModeAsk) + m.refreshViewportContent() + m.Viewport.GotoBottom() + return tea.Batch(cmd, m.runAskPromptHandoffCmd(rawInput)) + } + m.push(roleSystem, infoStyle.Render("Refining architectural idea through Senior Architect analysis...")) m.refreshViewportContent() m.Viewport.GotoBottom() @@ -988,11 +1028,11 @@ func (m *model) setMode(mode modes.Mode) tea.Cmd { if mode == modes.ModePlan || mode == modes.ModeInvestigate { m.planApproved = false } - // Transitioning from /plan to /build marks the plan as approved so - // the orchestrator never re-enters /plan for the same execution cycle. - if mode == modes.ModeBuild && m.resolver.Current() == modes.ModePlan { - m.planApproved = true - } + // HUMAN-IN-THE-LOOP: plan approval is now managed explicitly via + // planApprovalActions (Approve/Reject chips). The m.planApproved flag + // is set only when the user explicitly approves the plan through the + // action chip handler. The old auto-approve-on-transition behavior is + // removed — every /plan → /build transition now requires human sign-off. // ── HANDOFF SANITIZER (BUG 3): clear ALL transient raw-string state on // every mode transition so the target mode can never inherit stale @@ -1154,6 +1194,57 @@ func (m *model) buildHandoffTriggerContent(mode modes.Mode) string { return "" } +// buildStrictHandoffPayload creates a minimal, focused context for the /build +// task execution. It contains ONLY: +// 1. The exact target file path(s) for the current task +// 2. The exact staged task description +// 3. The raw relevant symbol definition/context from the codebase +// This prevents cognitive drift by stripping all conversational history, +// raw chat logs, and unrelated codebase files. +// retryBuildWithStrictDirective re-executes the current build task with a +// maximally strict instruction that prohibits any conversational output. +// The LLM is told to output ONLY SEARCH/REPLACE or FILE_CREATE blocks with +// zero preamble, zero explanation, zero greeting. +func (m *model) retryBuildWithStrictDirective() tea.Cmd { + tasks := m.sess.CurrentTasks + if len(tasks) == 0 { + return nil + } + // Find the current processing/failed task. + var targetTask *plan.Task + for i, t := range tasks { + if t.Status == "processing" || t.Status == "failed" || t.Status == "idle" { + targetTask = &tasks[i] + break + } + } + if targetTask == nil { + return nil + } + strictContent := fmt.Sprintf( + "## STRICT BUILD DIRECTIVE — ZERO CONVERSATIONAL TEXT\n\n"+ + "YOU ARE A CODE GENERATION TOOL. DO NOT OUTPUT ANY TEXT THAT IS NOT A CODE PATCH.\n\n"+ + "REQUIRED OUTPUT FORMAT (FIRST TOKEN MUST MATCH):\n"+ + "- For existing files: ```go:path/to/file.go\n <<<<<<< SEARCH\n ...\n =======\n ...\n >>>>>>>\n ```\n"+ + "- For new files: ```\n <<<<<<< FILE_CREATE: path/to/newfile.go\n ...\n >>>>>>> END_FILE\n ```\n\n"+ + "FORBIDDEN OUTPUT:\n"+ + "- Greetings, acknowledgments, summaries, explanations\n"+ + "- Questions, clarifications, suggestions\n"+ + "- Markdown that is not SEARCH/REPLACE or FILE_CREATE\n"+ + "- JSON, YAML, or any structured data format\n\n"+ + "TASK:\n"+ + "Step %d: %s\nTarget: %s\nDescription: %s\n\n"+ + "OUTPUT YOUR PATCH NOW:", + targetTask.StepNum, targetTask.Type, targetTask.Target, targetTask.Description) + m.push(roleSystem, "Conversational output detected. Re-triggering build with strict directive...") + m.sess.ClearHistory() + _ = m.sess.Save() + m.responseBuffer.Reset() + m.streamBuffer = "" + m.currentStreamContent = "" + return m.streamCmd(strictContent) +} + // buildStrictHandoffPayload creates a minimal, focused context for the /build // task execution. It contains ONLY: // 1. The exact target file path(s) for the current task @@ -1428,8 +1519,8 @@ func (m *model) handleCommand(cmd string) tea.Cmd { } return nil - case cmd == "/undo": - return m.runUndoCmd() + case strings.HasPrefix(cmd, "/undo"): + return m.runUndoCmd(cmd) case cmd == "/commit": if m.resolver.Current() != modes.ModeBuild { @@ -1624,14 +1715,25 @@ func (m *model) runBuildCmd(content string) tea.Cmd { } // Materialize PendingTodos into typed tasks if no staged tasks exist yet. + // Parse the formatted string "[TYPE] target — description" back into + // structured Task fields so the build dispatcher routes to the correct + // execution path (FILE_MUTATE/SHELL_EXEC) instead of the generic streaming + // path that produces conversational prose. if !hasStagedTasks && hasPendingTodos { var tasks []plan.Task for i, t := range m.handoffCtx.PendingTodos { + taskType, taskTarget, taskDesc := parsePendingTodo(t) + if taskType == "" { + taskType = "task" + } + if taskTarget == "" { + taskTarget = "workspace" + } tasks = append(tasks, plan.Task{ StepNum: i + 1, - Type: "task", - Target: "workspace", - Description: t, + Type: taskType, + Target: taskTarget, + Description: taskDesc, Status: "idle", }) } @@ -1645,6 +1747,41 @@ func (m *model) runBuildCmd(content string) tea.Cmd { return m.handleBuildRun(0) } +// parsePendingTodo extracts the task type, target, and description from a +// PendingTodos string formatted as: +// +// [] +// +// The icon prefix is stripped; the type is extracted from the first bracket +// pair; the target is the text between the closing bracket and the em-dash; +// the description is everything after the em-dash. Returns empty strings for +// any component that cannot be parsed, so the caller can apply defaults. +func parsePendingTodo(todo string) (taskType, taskTarget, taskDesc string) { + // Strip leading icon (non-space characters before the first space) + trimmed := strings.TrimSpace(todo) + if idx := strings.Index(trimmed, " "); idx > 0 { + trimmed = strings.TrimSpace(trimmed[idx+1:]) + } + + // Extract [TYPE] + if open := strings.Index(trimmed, "["); open >= 0 { + if close := strings.Index(trimmed[open:], "]"); close > 0 { + taskType = strings.TrimSpace(trimmed[open+1 : open+close]) + trimmed = strings.TrimSpace(trimmed[open+close+1:]) + } + } + + // Split on " — " to separate target from description + if idx := strings.Index(trimmed, " — "); idx >= 0 { + taskTarget = strings.TrimSpace(trimmed[:idx]) + taskDesc = strings.TrimSpace(trimmed[idx+3:]) + } else { + taskTarget = trimmed + } + + return +} + // handleHotfixCmd implements the $hot urgent hotfix workflow in /build mode. // // Flow: @@ -1982,7 +2119,26 @@ func (m *model) proposeBuildPatch(task *plan.Task) tea.Cmd { return buildProposalReadyMsg{Err: fmt.Errorf("build execution error: no provider configured")} } + // ── CRITICAL: Read the target file BEFORE the LLM call ────────── + // Without the actual file content in the prompt, the LLM hallucinates + // the original content (e.g. "Copyright (c) 2023 Jay") and generates + // a unified diff that can never match the file on disk, producing: + // "patch hunk does not match file content". + // + // The original is read once here and included in both the initial + // handoff and any retry handoff so the LLM always sees real content. + var orig string + if data, rerr := os.ReadFile(task.Target); rerr == nil { + orig = string(data) + } + baseHandoff := ctxpkg.SanitizeBuildHandoff(task, "") + if orig != "" { + baseHandoff += "\n\n### TARGET_FILE_CONTENT\n```\n" + orig + "\n```\n" + baseHandoff += "\nModify the above file content to fulfill the task. " + baseHandoff += "Output a SEARCH/REPLACE block (`<<<<<<< SEARCH`) or a unified diff. " + baseHandoff += "Do NOT output a full FILE: block — the file already exists." + } system := prompt.BuildContract() maxRetries := 2 handoff := baseHandoff @@ -2007,15 +2163,12 @@ func (m *model) proposeBuildPatch(task *plan.Task) tea.Cmd { cleaned := sanitizeFileOutput(resp.Content) - orig := "" - if data, rerr := os.ReadFile(task.Target); rerr == nil { - orig = string(data) - } - // Validate patch format BEFORE presenting to human. // If the snippet is ambiguous (no SEARCH/REPLACE markers, no diff // headers, significantly smaller than the original), reject early // and retry with explicit SEARCH/REPLACE formatting instructions. + // The retry prompt also includes the actual file content so the + // LLM can generate a correct patch. if execution.IsAmbiguousSnippet(orig, cleaned) { if attempt < maxRetries { handoff = fmt.Sprintf( @@ -2333,9 +2486,12 @@ func (m *model) handleBuildRun(stepNum int) tea.Cmd { m.push(roleStatus, fmt.Sprintf("executing step %d: %s — %s", targetTask.StepNum, targetTask.Type, targetTask.Target)) content := fmt.Sprintf( - "EXECUTION MODE — implement ONLY this task and output the code patch directly "+ - "(unified diff or FILE: block). Do NOT output JSON, do NOT restate the plan, "+ - "do NOT list other tasks.\n\n"+ + "EXECUTION MODE — implement ONLY this task. "+ + "ZERO conversational text, ZERO explanations, ZERO greetings, ZERO summaries.\n"+ + "YOUR FIRST OUTPUT TOKEN MUST BE A SEARCH/REPLACE BLOCK (for existing files) "+ + "OR A FILE_CREATE BLOCK (for new files).\n"+ + "Do NOT output JSON, do NOT restate the plan, do NOT list other tasks.\n"+ + "Do NOT ask questions, do NOT ask for clarification, do NOT acknowledge.\n\n"+ "Step %d: %s\nTarget: %s\nDescription: %s", targetTask.StepNum, targetTask.Type, targetTask.Target, targetTask.Description) @@ -4012,6 +4168,48 @@ func (m *model) handleChipActivation(action Action) tea.Cmd { return nil } + // Mode-switch command chips: /investigate, /plan, /build, /ask, /review + // These are NOT in validSystemCommands — they must be routed as mode + // transitions instead of falling through to handleCommand. + if mode, content, ok := parseModeShorthand(action.Command); ok { + m.modeChangeAuthorized = true + + // ── PLAN APPROVAL GATE ───────────────────────────────────────── + // When the user explicitly approves the plan via the action chip, + // set planApproved = true so the /build handoff engine fires. + // Without this explicit approval, /build remains blocked. + if action.ID == "approve-plan" { + m.planApproved = true + m.push(roleSystem, infoStyle.Render("✓ Plan approved. Transitioning to /build for execution...")) + } + + // ── PLAN REJECTION ───────────────────────────────────────────── + // When the user rejects the plan, clear all handoff context including + // staged tasks so no stale plan data leaks into the next cycle. + if action.ID == "reject-plan" { + m.push(roleSystem, infoStyle.Render("✗ Plan rejected. Clearing staged tasks...")) + m.handoffCtx = HandoffContext{} + m.handoffLedgerContent = "" + if m.sess != nil { + m.sess.ClearTasks() + m.sess.ContextLedger = nil + _ = m.sess.Save() + } + } + + m.handoffCtx.ProposedFix = "" + m.handoffLedgerContent = "" + m.currentResult = nil + cmd := m.setMode(mode) + if action.Query != "" { + return m.handleMessageContent(action.Query) + } + if content != "" { + return m.handleMessageContent(content) + } + return cmd + } + // Direct command capabilities: /commit, /undo, etc. return m.handleCommand(action.Command) } diff --git a/internal/ui/keys.go b/internal/ui/keys.go index b4016b5..53fe6e1 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -7,9 +7,76 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/PizenLabs/izen/internal/execution" + "github.com/PizenLabs/izen/internal/modes" ) func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // ── GLOBAL: Alt+O toggles reasoning block visibility ──────────── + if msg.String() == "alt+o" { + m.showReasoning = !m.showReasoning + m.refreshViewportContent() + if m.Ready && !m.userIsScrollingUp { + m.Viewport.GotoBottom() + } + return m, nil + } + + // ── GLOBAL: Alt+F / Option+F / Meta+F — Handoff from /ask to /investigate ── + // Checks the latest valid /ask Context Ledger (ask_handoff packet), and if + // present, transitions to /investigate with the ledger injected as context. + // If no valid /ask Context Ledger exists, rejects with a clear TUI notice. + if msg.String() == "alt+f" { + if m.state == StateProcessing || m.state == StateAwaitingApproval { + return m, nil + } + if m.streaming || m.agentRunning || m.reviewRunning || m.pipelineRunning { + return m, nil + } + + // Check for a valid /ask Context Ledger + hasAskHandoff := false + handoffContent := "" + if m.sess != nil && m.sess.ContextLedger != nil { + // Check for an "ask_handoff" packet in the ledger + for _, p := range m.sess.ContextLedger.Packets { + if p.Kind == "ask_handoff" { + hasAskHandoff = true + handoffContent = p.Payload + break + } + } + // Fallback: check Diagnostics if no ask_handoff packet found + if !hasAskHandoff && m.sess.ContextLedger.Diagnostics != "" { + hasAskHandoff = true + handoffContent = m.sess.ContextLedger.Diagnostics + } + } + // Also check the transient handoffLedgerContent + if !hasAskHandoff && m.handoffLedgerContent != "" { + hasAskHandoff = true + handoffContent = m.handoffLedgerContent + } + + if !hasAskHandoff || handoffContent == "" { + m.push(roleError, "No active Context Ledger from /ask. Run $prompt in any mode first to generate a Forensic Context Ledger.") + m.refreshViewportContent() + m.Viewport.GotoBottom() + return m, nil + } + + // Create Handoff Context from the ask handoff payload + m.handoffCtx.LastFailurePayload = handoffContent + m.handoffCtx.ProposedFix = handoffContent + m.handoffLedgerContent = handoffContent + + m.push(roleSystem, infoStyle.Render("Handing off /ask Context Ledger to /investigate...")) + // Transition mode to /investigate (clean transition) + m.modeChangeAuthorized = true + m.currentResult = nil + cmd := m.setMode(modes.ModeInvestigate) + return m, cmd + } + // ── StateProcessing: block input but allow viewport navigation ────── if m.state == StateProcessing { if m.Ready { diff --git a/internal/ui/model.go b/internal/ui/model.go index c502b18..8363c4c 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -128,9 +128,10 @@ type reviewResultMsg struct { // synthesis. It is dispatched from a background tea.Cmd (runPlanEngineCmd) so // the synchronous LLM call never blocks the Bubble Tea event loop. type planResultMsg struct { - Tasks []plan.Task - Err error - Handoff HandoffContext // echoed back so the handler can populate PendingTodos + Tasks []plan.Task + Err error + Handoff HandoffContext // echoed back so the handler can populate PendingTodos + IsFastTrack bool // if true, auto-approve plan — bypass approval gate } type agentStartMsg struct{ label string } @@ -534,10 +535,12 @@ type model struct { PreRenderedHistory string // Streaming - streamCh chan tea.Msg - responseBuffer strings.Builder - streaming bool - spinnerFrame int + streamCh chan tea.Msg + responseBuffer strings.Builder + reasoningBuffer strings.Builder + showReasoning bool + streaming bool + spinnerFrame int // lastSpinnerAdvance throttles spinner-frame advancement inside the 20ms // smoothStreamTickMsg loop to a ~100ms cadence, so the braille animation // stays visually consistent with the 100ms tickMsg loop while token @@ -1135,6 +1138,7 @@ func (m *model) resetStreamingState() { m.planPending = false m.spinnerFrame = 0 m.lastSpinnerAdvance = time.Time{} + m.reasoningBuffer.Reset() if m.streamParser != nil { m.streamParser = nil } @@ -1164,11 +1168,84 @@ func (m *model) reconcileSpinner() { m.planPending = false m.spinnerFrame = 0 m.lastSpinnerAdvance = time.Time{} + m.reasoningBuffer.Reset() if m.streamParser != nil { m.streamParser = nil } } +// extractReasoningContent scans raw stream text for reasoning sentinels +// (inserted by providers that surface delta.reasoning_content via SSE) and +// for inline ... tags (emitted by models that output reasoning +// directly in the message content). It separates reasoning into the dedicated +// reasoningBuffer and strips it from the visible content buffer. +// +// Reasoning sentinels use zero-width markers: \x00RSNG\x00...reasoning...\x00RSNG\x00 +func (m *model) extractReasoningContent() { + // 1. Extract provider-level reasoning sentinels from streamBuffer. + m.streamBuffer = m.extractSentinelReasoning(m.streamBuffer) + // 2. Extract inline tags from the already-rendered content. + m.currentStreamContent = m.extractThinkTags(m.currentStreamContent) +} + +// extractSentinelReasoning scans raw for reasoning sentinel markers +// (\x00RSNG\x00...\x00RSNG\x00) and moves the enclosed content into +// the reasoningBuffer. Returns the cleaned text with markers removed. +func (m *model) extractSentinelReasoning(raw string) string { + const sentinel = "\x00RSNG\x00" + if !strings.Contains(raw, sentinel) { + return raw + } + var clean strings.Builder + remaining := raw + for { + start := strings.Index(remaining, sentinel) + if start < 0 { + clean.WriteString(remaining) + break + } + clean.WriteString(remaining[:start]) + rest := remaining[start+len(sentinel):] + end := strings.Index(rest, sentinel) + if end < 0 { + clean.WriteString(rest) + break + } + m.reasoningBuffer.WriteString(rest[:end]) + remaining = rest[end+len(sentinel):] + } + return clean.String() +} + +// extractThinkTags scans text for ... tags and moves the +// enclosed content into the reasoningBuffer. Returns the cleaned text +// with tags stripped. +func (m *model) extractThinkTags(text string) string { + if !strings.Contains(text, "") { + return text + } + var clean strings.Builder + remaining := text + for { + start := strings.Index(remaining, "") + if start < 0 { + clean.WriteString(remaining) + break + } + clean.WriteString(remaining[:start]) + rest := remaining[start+len(""):] + end := strings.Index(rest, "") + if end < 0 { + // No closing tag yet — keep remaining in content for now + clean.WriteString(remaining[start:]) + break + } + m.reasoningBuffer.WriteString(rest[:end]) + remaining = rest[end+len(""):] + } + return clean.String() +} + func (m *model) flushPendingRecords() tea.Cmd { if len(m.records) == 0 { return nil @@ -1251,6 +1328,14 @@ func (m *model) refreshViewportContent() { content.WriteString("\n") } } + + // ── Collapsible reasoning block ────────────────────────────── + reasoningBlock := m.renderReasoningBlock(m.width) + if reasoningBlock != "" { + content.WriteString(reasoningBlock) + content.WriteString("\n") + } + sp := m.renderFlowingSpinner() status := "streaming…" if m.agentRunning { diff --git a/internal/ui/stream.go b/internal/ui/stream.go index c0d21d3..8862966 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -76,6 +76,7 @@ func (m *model) streamCmd(content string) tea.Cmd { m.streaming = true m.spinnerFrame = 0 m.responseBuffer.Reset() + m.reasoningBuffer.Reset() // ── TRANSIENT BUFFER RESET (1-TURN LATENCY FIX) ─────────────────── // Explicitly clear all accumulated raw-string buffers before launching the // stream so the rendering pipeline cannot leak or re-send leftover bytes diff --git a/internal/ui/update.go b/internal/ui/update.go index 255fd5f..d4c4453 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -56,9 +56,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // ── GLOBAL INTERCEPT: [Alt+P] Toggle Hotkey ──────────────────────────── + // ── GLOBAL INTERCEPT: [Alt+P] Toggle Proposal, [Alt+O] Toggle Reasoning ── if keyMsg, ok := msg.(tea.KeyMsg); ok { - if keyMsg.String() == "alt+p" { + switch keyMsg.String() { + case "alt+p": if m.state == StateAwaitingApproval && len(m.pendingProposals) > 0 { m.pendingProposals[0].Expanded = !m.pendingProposals[0].Expanded m.proposalDiffOffset = 0 @@ -67,6 +68,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Viewport.GotoBottom() return m, nil } + case "alt+o": + m.showReasoning = !m.showReasoning + m.refreshViewportContent() + if m.Ready && !m.userIsScrollingUp { + m.Viewport.GotoBottom() + } + return m, nil } } @@ -341,16 +349,23 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(msg.records) == 0 { m.push(roleSystem, "Investigation complete — no structured findings to report.") } - m.push(roleStatus, "Investigation complete. Context-Ledger ready for /plan handoff.") - // PERSISTENT NAVIGATION CHIPS (BUG 1): always populate the navigation - // controls so the user is never left with a dead viewport after a fast - // (cached) transition. "📋 Plan Solution" submits /plan against the - // structured diagnostic payload; "🔄 Re-investigate" re-runs /investigate. - m.currentResult = investigateResultActions() + m.push(roleStatus, "Investigation complete. Auto-transitioning to /plan for execution synthesis...") + + // ── AUTO-HANDOFF: /investigate -> /plan ──────────────────────────── + // Once investigation completes with a valid Context Ledger, automatically + // transition to /plan to synthesize the structured execution timeline. + // The plan will be rendered for user review (Approve, Edit, Add/Remove, + // Reject) — automatic execution without human approval is strictly forbidden. + m.handoffCtx.ProposedFix = m.handoffLedgerContent + var cmds []tea.Cmd + if m.handoffLedgerContent != "" { + m.modeChangeAuthorized = true + cmds = append(cmds, m.setMode(modes.ModePlan)) + } m.refreshViewportContent() m.Viewport.GotoBottom() - flush := m.flushPendingRecords() - return m, flush + cmds = append(cmds, m.flushPendingRecords()) + return m, tea.Batch(cmds...) case planResultMsg: // Terminal handler for the asynchronous PlanEngine synthesis. Only here @@ -399,26 +414,44 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.handoffCtx.PendingTodos[i] = icon + " [" + t.Type + "] " + t.Target + " — " + t.Description } - m.push(roleStatus, fmt.Sprintf("Plan staged: %d task(s). Use /build to execute.", len(msg.Tasks))) + if msg.IsFastTrack { + m.planApproved = true + m.push(roleStatus, accentStyle.Render(fmt.Sprintf("[Fast-Track] Plan auto-approved: %d task(s). Type /build to execute.", len(msg.Tasks)))) + } else { + m.push(roleStatus, fmt.Sprintf("Plan staged: %d task(s). Approve (Alt+P) or Reject (Alt+R).", len(msg.Tasks))) + } // Render the staged task list into the viewport so the developer can // see exactly what /build will execute — Principal Engineer format. + // Use [ ] checkbox markers for each pending task to create an + // interactive todo checklist look in the TUI. + // Also expose the plan approval action chips — the user must explicitly + // approve the plan before /build execution begins. + // Fast-track plans are auto-approved — show execute-build + reset actions + // so action chips are visible from ANY mode (including /ask). + // Non-fast-track plans show the explicit approval gate. + if msg.IsFastTrack { + m.currentResult = fastTrackPlanActions() + } else { + m.currentResult = planApprovalActions() + } var tb strings.Builder tb.WriteString(boldSapphireStyle.Render(Icon.Blueprint+" STRATEGIC ARCHITECTURAL BLUEPRINT") + "\n") tb.WriteString(" ▸ Impact Domain : Execution Layer — Dependency Resolution\n") tb.WriteString(" ▸ Risk Evaluation : Low — Scoped dependency resolution\n") tb.WriteString(" ▸ Verification Vector: Build + Test pipeline\n") tb.WriteString("\n") - tb.WriteString(boldMauveStyle.Render(Icon.Timeline+" STAGED EXECUTION TIMELINE") + "\n") + tb.WriteString(boldMauveStyle.Render(Icon.Timeline+" TODO CHECKLIST") + "\n") for _, t := range msg.Tasks { icon, track := planTrackIcon(t) - fmt.Fprintf(&tb, "%s [%s] %s\n", icon, track, t.Target) - if t.Rationale != "" { - fmt.Fprintf(&tb, " ↳ Rationale: %s\n", t.Rationale) + fmt.Fprintf(&tb, "[ ] %s [%s] %s\n", icon, track, t.Target) + if t.Description != "" { + fmt.Fprintf(&tb, " %s\n", t.Description) + } + if t.Rationale != "" && t.Rationale != t.Description { + fmt.Fprintf(&tb, " %s\n", t.Rationale) } if t.Solution != "" { - fmt.Fprintf(&tb, " ↳ Expected Solution: %s\n", t.Solution) - } else if t.Description != "" && t.Rationale == "" { - fmt.Fprintf(&tb, " ↳ Rationale: %s\n", t.Description) + fmt.Fprintf(&tb, " %s\n", t.Solution) } } m.push(roleStatus, tb.String()) @@ -606,6 +639,16 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.resolver.Current() == modes.ModeBuild { m.push(roleSystem, "Build verification complete.") + + // ── AUTO-HANDOFF: /build → /review ────────────────────────── + // All build tasks completed successfully and verification tests + // passed. Transition to /review for a full architectural review + // of the changes. This enforces the automated handoff chain: + // /investigate → /plan → approval → /build → /review. + if msg.passed { + m.modeChangeAuthorized = true + m.setMode(modes.ModeReview) + } } } @@ -886,6 +929,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if hasNext && m.resolver.Current() == modes.ModeBuild { return m, tea.Batch(flush, m.handleBuildRun(0)) } + // ── AUTO-HANDOFF: /build → /review ────────────────────────────── + // All SHELL_EXEC steps completed successfully and no more tasks + // remain. Transition to /review for a full architectural review. + if !hasNext && m.resolver.Current() == modes.ModeBuild { + m.modeChangeAuthorized = true + m.setMode(modes.ModeReview) + } return m, flush case logInputMsg: @@ -1342,6 +1392,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if len(m.streamBuffer) > 0 { + // Extract reasoning content (sentinels + tags) before + // emitting to the visible content buffer. + m.extractReasoningContent() + // Emit word-aligned chunks for a natural reading rhythm. emit := 0 minChars := 3 @@ -1449,6 +1503,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.streamBuffer = "" m.streamTickActive = false } + // Final reasoning extraction from any remaining content + m.extractReasoningContent() if m.sess.ObjectiveState != nil && m.sess.ObjectiveState.CurrentStatus == domain.ObjectiveExecuting { m.sess.ObjectiveState.CurrentStatus = domain.ObjectivePlanned @@ -1475,6 +1531,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // the downstream JSON parser or render pipeline. Lines matching // telemetry/error markers are removed from leading/trailing context. final = sanitizeFinalContent(final) + // Strip any remaining reasoning sentinels from final content + final = m.extractSentinelReasoning(final) // Append the completed turn to PreRenderedHistory and freeze state. m.push(roleAI, final) @@ -1704,7 +1762,42 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(jsonResult.Tasks) > 0 { tasks := jsonResult.Tasks m.sess.StageTaskList(&tasks) - m.push(roleStatus, "System status: Plan staged. Use /build to execute changes.") + // Populate PendingTodos from JSON tasks so the plan view + // renders the interactive TODO checklist and the /build + // auto-trigger picks up the task payload. + if len(m.handoffCtx.PendingTodos) == 0 { + m.handoffCtx.PendingTodos = make([]string, len(tasks)) + for i, t := range tasks { + icon := Icon.ShellExec + if t.Type == "FILE_MUTATE" || t.Type == "DIFF_PATCH" || t.Type == "ATOMIC_REPLACE" { + icon = Icon.SrcPatch + } + m.handoffCtx.PendingTodos[i] = icon + " [" + t.Type + "] " + t.Target + " — " + t.Description + } + } + m.currentResult = planApprovalActions() + var tb strings.Builder + tb.WriteString(boldSapphireStyle.Render(Icon.Blueprint+" STRATEGIC ARCHITECTURAL BLUEPRINT") + "\n") + tb.WriteString(" \u25b8 Impact Domain : Execution Layer — Dependency Resolution\n") + tb.WriteString(" \u25b8 Risk Evaluation : Low — Scoped dependency resolution\n") + tb.WriteString(" \u25b8 Verification Vector: Build + Test pipeline\n") + tb.WriteString("\n") + tb.WriteString(boldMauveStyle.Render(Icon.Timeline+" TODO CHECKLIST") + "\n") + for _, t := range jsonResult.Tasks { + icon, track := planTrackIcon(t) + fmt.Fprintf(&tb, "[ ] %s [%s] %s\n", icon, track, t.Target) + if t.Description != "" { + fmt.Fprintf(&tb, " %s\n", t.Description) + } + if t.Rationale != "" && t.Rationale != t.Description { + fmt.Fprintf(&tb, " %s\n", t.Rationale) + } + if t.Solution != "" { + fmt.Fprintf(&tb, " %s\n", t.Solution) + } + } + m.push(roleStatus, tb.String()) + m.push(roleStatus, "Plan staged. Approve or reject with the action bar below.") } } else { errMsg := "plan rejected: output does not conform to JSON schema" @@ -1723,6 +1816,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sess.ClearHistory() _ = m.sess.Save() } + // Ensure the plan view shows approval actions even when the + // PlanEngine path (planResultMsg) was bypassed. + if m.currentResult == nil && len(m.handoffCtx.PendingTodos) > 0 { + m.currentResult = planApprovalActions() + } } if m.resolver.Current() == modes.ModeBuild && m.state != StateAwaitingApproval { @@ -1759,6 +1857,20 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ti.Blur() m.refreshViewportContent() } + } else { + // ── ZERO-TOLERANCE CONVERSATIONAL GUARD ───────────── + // If the build LLM produced zero SEARCH/REPLACE or FILE_CREATE + // blocks, it output conversational prose instead of code patches. + // This is a hard violation of the build contract. Surface the error + // and request a re-generation with strict tool-only directive. + conversational := build.IsConversationalOutput(final) + if conversational { + regen := m.retryBuildWithStrictDirective() + if regen != nil { + return m, regen + } + m.push(roleError, "[BUILD GUARD] LLM output contained only conversational text — no SEARCH/REPLACE or FILE_CREATE blocks. Re-run /build with a stricter task description.") + } } m.sess.ClearTasks() } diff --git a/internal/ui/view.go b/internal/ui/view.go index b1ba18b..b49fb63 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -163,6 +163,113 @@ func (m *model) viModeLabel() string { } } +// renderReasoningBlock renders the collapsible reasoning block during streaming. +// When reasoning content is available, it shows: +// +// Collapsed (m.showReasoning == false): +// ⚙ Thinking... [Alt+O to expand reasoning] +// +// Expanded (m.showReasoning == true): +// ┌─ Reasoning ──────────────────────────┐ +// │ │ +// └───────────────────────────────────────┘ +func (m *model) renderReasoningBlock(width int) string { + if m.reasoningBuffer.Len() == 0 { + return "" + } + + reasoningText := m.reasoningBuffer.String() + if !m.showReasoning { + // Collapsed: single-line spinner hint + sp := m.renderFlowingSpinner() + hint := sp + " " + dimmedStyle.Render("Thinking... ") + mutedStyle.Render("[Alt+O to expand]") + return hint + } + + // Expanded: render full reasoning content in a dimmed block + if width < 40 { + width = 40 + } + + var b strings.Builder + + // Top border with title + title := "Reasoning" + topFiller := width - lipgloss.Width(title) - 6 + if topFiller < 0 { + topFiller = 0 + } + b.WriteString(dimmedStyle.Render("┌─ "+title+" "+strings.Repeat("─", topFiller)+"┐") + "\n") + + // Reasoning content lines — dimmed and wrapped + lines := strings.Split(reasoningText, "\n") + for _, line := range lines { + line = strings.TrimRight(line, " \r") + if line == "" { + b.WriteString(dimmedStyle.Render("│ ") + " " + dimmedStyle.Render(" │") + "\n") + continue + } + // Word-wrap to available width + avail := width - 4 + if avail < 10 { + avail = 10 + } + wrapped := wrapString(line, avail) + for _, wl := range wrapped { + padded := wl + strings.Repeat(" ", avail-lipgloss.Width(wl)) + b.WriteString(dimmedStyle.Render("│ ") + mutedStyle.Render(padded) + dimmedStyle.Render(" │") + "\n") + } + } + + // Bottom border + bottomFiller := width - 4 + if bottomFiller < 0 { + bottomFiller = 0 + } + b.WriteString(dimmedStyle.Render("└" + strings.Repeat("─", bottomFiller) + "┘")) + + return b.String() +} + +// wrapString wraps text to a given width, splitting at word boundaries. +func wrapString(text string, width int) []string { + if len(text) == 0 || width < 1 { + return []string{text} + } + var result []string + words := strings.Fields(text) + if len(words) == 0 { + // No whitespace — hard wrap + runes := []rune(text) + for i := 0; i < len(runes); i += width { + end := i + width + if end > len(runes) { + end = len(runes) + } + result = append(result, string(runes[i:end])) + } + return result + } + var line strings.Builder + for _, word := range words { + wordW := lipgloss.Width(word) + if line.Len() > 0 && line.Len()+1+wordW > width { + result = append(result, line.String()) + line.Reset() + line.WriteString(word) + } else { + if line.Len() > 0 { + line.WriteString(" ") + } + line.WriteString(word) + } + } + if line.Len() > 0 { + result = append(result, line.String()) + } + return result +} + // renderProposalBlock renders the interactive proposal/processing dock // between the viewport and the input line, framed for clear isolation. func (m *model) renderProposalBlock() string { diff --git a/internal/ui/workspace.go b/internal/ui/workspace.go index 32ac589..b7c2b05 100644 --- a/internal/ui/workspace.go +++ b/internal/ui/workspace.go @@ -233,3 +233,109 @@ func (m *model) BuildWorkspace() Workspace { } return v.BuildWorkspace(m) } + +// ── /ask ─────────────────────────────────────────────────────────────────── +// Read-only mode: no handoff capabilities are exposed. +type askView struct{} + +func (askView) BuildWorkspace(m *model) Workspace { + ws := m.assembleScreen(m.currentResultActions()) + ws.Header = "ask · explain, inspect, understand" + return ws +} + +// ── /plan ────────────────────────────────────────────────────────────────── +type planView struct{} + +func (planView) BuildWorkspace(m *model) Workspace { + var actions []Action + if len(m.handoffCtx.PendingTodos) > 0 { + if m.planApproved { + actions = append(actions, Action{ + ID: "execute-build", + Label: "▶ Execute Build", + Shortcut: "alt+b", + Command: "/build", + Enabled: true, + Priority: 100, + }) + actions = append(actions, Action{ + ID: "reject-plan", + Label: "✗ Reset & Clear", + Shortcut: "alt+r", + Command: "/ask", + Enabled: true, + Priority: 90, + }) + } else { + actions = append(actions, Action{ + ID: "approve-plan", + Label: "✓ Approve & Run /build", + Shortcut: "alt+p", + Command: "/build", + Enabled: true, + Priority: 100, + }) + actions = append(actions, Action{ + ID: "reject-plan", + Label: "✗ Reject & Back", + Shortcut: "alt+r", + Command: "/ask", + Enabled: true, + Priority: 90, + }) + actions = append(actions, Action{ + ID: "execute-patch", + Label: "> Execute & Verify Patch", + Shortcut: "alt+c", + Command: "/build", + Enabled: true, + Priority: 80, + }) + } + } else if len(m.currentResultActions()) > 0 { + actions = append(actions, m.currentResultActions()...) + } + ws := m.assembleScreen(actions) + ws.Header = "plan · architecture, migrations, refactors — strategic blueprint" + return ws +} + +// ── /build ───────────────────────────────────────────────────────────────── +type buildView struct{} + +func (buildView) BuildWorkspace(m *model) Workspace { + ws := m.assembleScreen(m.currentResultActions()) + ws.Header = "build · implement, refactor, elevate" + return ws +} + +// ── /investigate ─────────────────────────────────────────────────────────── +type investigateView struct{} + +func (investigateView) BuildWorkspace(m *model) Workspace { + var actions []Action + if m.handoffCtx.ProposedFix != "" { + actions = append(actions, Action{ + ID: "formulate-plan", + Label: "Formulate Execution Plan", + Shortcut: "alt+b", + Command: "/plan", + Query: "Formulate an execution plan for the proposed fix:\n\n" + m.handoffCtx.ProposedFix, + Enabled: true, + Priority: 100, + }) + } + ws := m.assembleScreen(actions) + ws.Header = "investigate · debug, trace, root-cause" + return ws +} + +// ── /review ──────────────────────────────────────────────────────────────── +type reviewView struct{} + +func (reviewView) BuildWorkspace(m *model) Workspace { + ws := m.assembleScreen(m.currentResultActions()) + ws.Header = "review · analyze, critique, improve" + return ws +} diff --git a/internal/ui/workspaces.go b/internal/ui/workspaces.go deleted file mode 100644 index 5267f2e..0000000 --- a/internal/ui/workspaces.go +++ /dev/null @@ -1,95 +0,0 @@ -package ui - -import ( - "fmt" - "strings" -) - -// The builders below are registered explicitly into a Registry at application -// bootstrap (see program.go). No init()-based self-registration: wiring is -// deterministic and lives in one place. - -// ── /ask ─────────────────────────────────────────────────────────────────── -// Read-only mode: no handoff capabilities are exposed. -type askView struct{} - -func (askView) BuildWorkspace(m *model) Workspace { - ws := m.assembleScreen(m.currentResultActions()) - ws.Header = "ask · explain, inspect, understand" - return ws -} - -// ── /plan ────────────────────────────────────────────────────────────────── -type planView struct{} - -func (planView) BuildWorkspace(m *model) Workspace { - var actions []Action - if len(m.handoffCtx.PendingTodos) > 0 { - var todoBlock strings.Builder - todoBlock.WriteString("Execute the planned staged execution timeline:\n") - for _, t := range m.handoffCtx.PendingTodos { - fmt.Fprintf(&todoBlock, " %s\n", t) - } - actions = append(actions, Action{ - ID: "execute-patch", - Label: "Execute & Verify Patch", - Shortcut: "alt+c", - Command: "/build", - Query: todoBlock.String(), - Enabled: true, - Priority: 100, - }) - } else if len(m.currentResultActions()) > 0 { - // Fallback: when no plan was staged (zero tasks / error), surface the - // baseline Action Chips from the current workflow result so the user - // is never left with a dead viewport and no buttons. - actions = append(actions, m.currentResultActions()...) - } - ws := m.assembleScreen(actions) - ws.Header = "plan · architecture, migrations, refactors — strategic blueprint" - return ws -} - -// ── /build ───────────────────────────────────────────────────────────────── -type buildView struct{} - -func (buildView) BuildWorkspace(m *model) Workspace { - // Build exposes the post-verification capability (commit / rollback) from - // the current workflow result. - ws := m.assembleScreen(m.currentResultActions()) - ws.Header = "build · implement, refactor, elevate" - return ws -} - -// ── /investigate ─────────────────────────────────────────────────────────── -type investigateView struct{} - -func (investigateView) BuildWorkspace(m *model) Workspace { - var actions []Action - if m.handoffCtx.ProposedFix != "" { - actions = append(actions, Action{ - ID: "formulate-plan", - Label: "Formulate Execution Plan", - Shortcut: "alt+b", - Command: "/plan", - Query: "Formulate an execution plan for the proposed fix:\n\n" + m.handoffCtx.ProposedFix, - Enabled: true, - Priority: 100, - }) - } - ws := m.assembleScreen(actions) - ws.Header = "investigate · debug, trace, root-cause" - return ws -} - -// ── /review ──────────────────────────────────────────────────────────────── -type reviewView struct{} - -func (reviewView) BuildWorkspace(m *model) Workspace { - // Review exposes the failure-investigation capability only from the - // current workflow result (e.g. a $test that just failed in review) — not - // a payload carried over from an earlier session. - ws := m.assembleScreen(m.currentResultActions()) - ws.Header = "review · analyze, critique, improve" - return ws -}