diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5af45b1..5022cf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,14 +9,6 @@ on: branches: [main] jobs: - docs-consistency: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Check docs consistency - run: ./scripts/check-doc-consistency.sh - lint: runs-on: ubuntu-latest steps: diff --git a/AGENTS.md b/AGENTS.md index abfc7cf..ca53cf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ - `make test-quick`: fast local checks during iteration. - `make lint`: runs formatting and static analysis (`go fmt`, `go vet`, `staticcheck`, optional `golangci-lint`). - `go test ./...`: baseline CI-style test run. -- `./scripts/check-doc-consistency.sh`: validates Markdown/doc sync rules used by CI. + ## Coding Style & Naming Conventions @@ -94,11 +94,8 @@ Brand names and logos are trademarks of their respective owners; usage here indi - taskwing bootstrap -- taskwing goal "" - taskwing ask "" - taskwing task -- taskwing plan status -- taskwing slash - taskwing mcp - taskwing doctor - taskwing config diff --git a/CHANGELOG.md b/CHANGELOG.md index ae28bcf..81b044b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,35 +9,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Focused `taskwing goal ""` command for one-shot clarify -> generate -> activate flow. -- Hard-break CLI surface reduction to core execution workflow commands. -- Local-only default server bind and strict CORS allowlist behavior. -- Workflow contract documentation (`docs/WORKFLOW_CONTRACT_V1.md`) with hard-gate refusal language and KPIs. -- Workflow operations docs for activation and feedback loops (`docs/WORKFLOW_PACK.md`, `docs/PROMPT_FAILURES_LOG.md`). -- Prompt reliability tests for slash command contracts and cross-assistant command description parity. +- `/taskwing:context` slash command for full project knowledge dump. +- Security hardening: freshness validation, stricter input sanitization. +- Prompt reliability tests for slash command contracts. +- Kill tables and operating principles in skill prompts. +- Workflow contract injection via SessionStart hook. ### Changed -- Updated product messaging to the focused motto: - - "TaskWing helps turn a goal into executed tasks with persistent context across AI sessions." -- Updated slash and MCP prompt contracts to unified `task` and `plan` action-based interfaces. -- Purged stale/outdated architecture documentation that no longer matches shipped behavior. -- Reworked `/taskwing:plan`, `/taskwing:next`, `/taskwing:done`, and `/taskwing:debug` prompts as explicit process contracts with hard gates and refusal fallbacks. -- Updated slash command descriptions to trigger-focused "Use when ..." phrasing across assistant command generation. -- Session initialization output now injects TaskWing Workflow Contract v1 for hook-enabled assistants. +- Consolidated slash commands from 8 to 4: `plan`, `next`, `done`, `context`. +- Planning is now MCP-tool-only (removed `taskwing plan` and `taskwing goal` CLI commands). +- Unified context API replaces separate status/ask workflows. +- Updated slash command and MCP prompt contracts to match reduced surface. +- Product messaging focused: "TaskWing helps turn a goal into executed tasks with persistent context across AI sessions." + +### Removed + +- `taskwing goal` and `taskwing plan` CLI commands (use `/taskwing:plan` or `plan` MCP tool). +- Slash commands: `/taskwing:ask`, `/taskwing:remember`, `/taskwing:status`, `/taskwing:debug`, `/taskwing:explain`, `/taskwing:simplify`. +- Interactive plan TUI (`internal/ui/plan_tui.go`). +- Net reduction of ~1,100 lines. ### Fixed -- **RootPath resolution**: Reject `MarkerNone` contexts in `GetMemoryBasePath` to prevent accidental writes to `~/.taskwing/memory.db`. Also reject `.taskwing` markers above multi-repo workspaces during detection walk-up. (`TestRootPathResolution`, `TestBootstrapRepro_RootPathResolvesToHome`) -- **FK constraint failures**: `LinkNodes` now pre-checks node existence before INSERT to avoid SQLite error 787. Duplicate edges handled gracefully. (`TestKnowledgeLinking_NoFK`) -- **IsMonorepo misclassification**: `Detect()` now checks `hasNestedProjects()` in the `MarkerNone` fallback, so multi-repo workspaces are correctly classified. Resolves disagreement between `Detect()` and `DetectWorkspace()`. (`TestIsMonorepoDetection`, `TestBootstrapRepro_IsMonorepoMisclassification`) -- **Zero docs loaded**: Added `LoadForServices` to `DocLoader` for multi-repo workspaces. Wired into `RunDeterministicBootstrap` via workspace auto-detection. (`TestDocIngestion`, `TestSubrepoMetadataExtraction`) -- **Sub-repo metadata**: Verified per-repo workspace context in node storage with proper isolation and cross-workspace linking. (`TestSubrepoMetadataPresent`) -- **Claude MCP drift**: Added filesystem-based drift detection tests with evidence traceability and Gate 3 consent enforcement for global mutations. (`TestClaudeDriftDetection`) -- **Hallucinated findings**: Gate 3 enforcement in `NewFindingWithEvidence` — findings without evidence start as "skipped". Added `HasEvidence()` and `NeedsHumanVerification()` to `Finding`. (`TestGate3_Enforcement`, `TestParseJSONResponse_Hallucination`) -- Priority scheduling semantics corrected (lower numeric priority executes first). -- Unknown slash subcommands now fail explicitly instead of silently falling back. -- MCP plan action descriptions aligned with implemented behavior. +- RootPath resolution: reject `MarkerNone` contexts to prevent writes to `~/.taskwing/memory.db`. +- FK constraint failures: `LinkNodes` pre-checks node existence before INSERT. +- IsMonorepo misclassification in `MarkerNone` fallback. +- Zero docs loaded for multi-repo workspaces. +- Claude MCP drift detection with evidence traceability. +- Hallucinated findings: Gate 3 enforcement requires evidence. +- Priority scheduling semantics (lower numeric = execute first). +- Unknown slash subcommands fail explicitly instead of silent fallback. ## [0.9.2] - 2025-08-30 diff --git a/CLAUDE.md b/CLAUDE.md index ef85013..88f04ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,15 +83,11 @@ TaskWing is a local-first AI knowledge layer. It extracts architectural decision cmd/ # Cobra CLI commands ├── root.go # Base command, global flags (--json, --verbose, --preview, --quiet) ├── bootstrap.go # Auto-generate knowledge from repo -├── goal.go # Goal-first flow: clarify -> generate -> activate ├── knowledge.go # View stored project knowledge nodes -├── plan.go # Plan lifecycle management ├── task.go # Task lifecycle management -├── slash.go # Slash command content for assistants ├── mcp_server.go # MCP server for AI tool integration ├── doctor.go # Diagnostics and integration repair ├── config.go # Provider and runtime configuration -├── start.go # Local API/dashboard runtime ├── hook.go # Hook handlers used by assistant integrations └── version.go # Version output @@ -235,7 +231,7 @@ Increment when: **NOT MINOR**: Internal refactors, new internal modules, code reorganization -Examples: new `taskwing goal` command, new `--format` flag, adding Gemini provider +Examples: new `taskwing config` subcommand, new `--format` flag, adding Gemini provider ### MAJOR (X.0.0) - Breaking changes only @@ -353,11 +349,8 @@ Brand names and logos are trademarks of their respective owners; usage here indi - `taskwing bootstrap` -- `taskwing goal ""` - `taskwing ask ""` - `taskwing task` -- `taskwing plan status` -- `taskwing slash` - `taskwing mcp` - `taskwing doctor` - `taskwing config` diff --git a/GEMINI.md b/GEMINI.md index eb5e75f..7739e64 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -77,11 +77,9 @@ The system is composed of a CLI tool with an embedded MCP server and a web dashb | Command | Description | | :------------------- | :---------------------------------------- | | `taskwing bootstrap` | Initialize project memory | -| `taskwing goal` | Create and activate a plan | | `taskwing plan` | Manage development plans | | `taskwing task` | Manage execution tasks | | `taskwing start` | Start API/watch/dashboard services | -| `taskwing slash` | Output slash command prompts for AI tools | ### Frontend (`dashboard/`) @@ -232,11 +230,8 @@ Brand names and logos are trademarks of their respective owners; usage here indi - `taskwing bootstrap` -- `taskwing goal ""` - `taskwing ask ""` - `taskwing task` -- `taskwing plan status` -- `taskwing slash` - `taskwing mcp` - `taskwing doctor` - `taskwing config` diff --git a/README.md b/README.md index ea51cf9..5cb3e81 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,8 @@ taskwing bootstrap # 2. Connect to your AI tool taskwing mcp install claude # or: cursor, gemini, codex, copilot, opencode -# 3. Set a goal and go -taskwing goal "Add Stripe billing" -# -> Plan decomposed into 5 executable tasks - -# 4. Execute with your AI assistant +# 3. Plan and execute with your AI assistant +/taskwing:plan # Create a plan via MCP /taskwing:next # Get next task with full context # ...work... /taskwing:done # Mark complete, advance to next @@ -156,7 +153,7 @@ Brand names and logos are trademarks of their respective owners; usage here indi | Capability | Description | |:-----------|:------------| | **Local knowledge** | Extracts decisions, patterns, and constraints into local SQLite | -| **Goal to tasks** | Turns a goal into an executable plan with decomposed tasks | +| **Plan to tasks** | Turns a plan into decomposed tasks with architecture context | | **AI-driven lifecycle** | Task execution -- next, start, complete, verify | | **Code analysis** | Symbol search, call graphs, impact analysis, simplification | | **Root cause first** | AI-powered diagnosis before proposing fixes | @@ -168,15 +165,10 @@ Use these from your AI assistant once connected: | Command | When to use | |:--------|:------------| -| `/taskwing:ask` | Search project knowledge (decisions, patterns, constraints) | -| `/taskwing:remember` | Persist a decision, pattern, or insight to project memory | +| `/taskwing:plan` | Clarify a goal and build an approved execution plan | | `/taskwing:next` | Start the next approved task with full context | | `/taskwing:done` | Complete the current task after verification | -| `/taskwing:status` | Check current task progress and acceptance criteria | -| `/taskwing:plan` | Clarify a goal and build an approved execution plan | -| `/taskwing:debug` | Root-cause-first debugging before proposing fixes | -| `/taskwing:explain` | Deep explanation of a code symbol and its call graph | -| `/taskwing:simplify` | Simplify code while preserving behavior | +| `/taskwing:context` | Get full project knowledge dump for complete architectural context |
MCP setup (manual) @@ -252,11 +244,8 @@ Or configure interactively: `taskwing config` - `taskwing bootstrap` -- `taskwing goal ""` - `taskwing ask ""` - `taskwing task` -- `taskwing plan status` -- `taskwing slash` - `taskwing mcp` - `taskwing doctor` - `taskwing config` diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 984dfed..dd6d237 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -71,7 +71,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error { TraceFile: getStringFlag(cmd, "trace-file"), Verbose: viper.GetBool("verbose"), Quiet: viper.GetBool("quiet"), - Debug: getBoolFlag(cmd, "debug"), + Debug: getBoolFlag(cmd, "debug"), } // Validate flags early - fail fast on contradictions @@ -158,28 +158,28 @@ func runBootstrap(cmd *cobra.Command, args []string) error { // ═══════════════════════════════════════════════════════════════════════ plan := bootstrap.DecidePlan(snapshot, flags) - // Always show plan summary (even in quiet mode, single line) - fmt.Print(bootstrap.FormatPlanSummary(plan, flags.Quiet)) - - // Handle error mode + // Handle error mode early (before any output) if plan.Mode == bootstrap.ModeError { + fmt.Print(bootstrap.FormatPlanSummary(plan, flags.Quiet)) return plan.Error } - // Handle preview mode - show plan and exit - if flags.Preview { - fmt.Println("\n💡 Preview mode - no changes made.") - return nil - } - - // Handle NoOp mode + // Handle NoOp mode early if plan.Mode == bootstrap.ModeNoOp { + fmt.Print(bootstrap.FormatPlanSummary(plan, flags.Quiet)) if !flags.Quiet { fmt.Println("\n✅ Nothing to do - configuration is up to date.") } return nil } + // Handle preview mode + if flags.Preview { + fmt.Print(bootstrap.FormatPlanSummary(plan, flags.Quiet)) + fmt.Println("\n💡 Preview mode - no changes made.") + return nil + } + // ═══════════════════════════════════════════════════════════════════════ // PHASE 3: Execute Plan // ═══════════════════════════════════════════════════════════════════════ @@ -213,6 +213,9 @@ func runBootstrap(cmd *cobra.Command, args []string) error { } } + // Show plan summary AFTER repo selection so it reflects the chosen scope + fmt.Print(bootstrap.FormatPlanSummary(plan, flags.Quiet)) + // Execute actions in order for _, action := range plan.Actions { if err := executeAction(cmd.Context(), action, svc, cwd, flags, plan, llmCfg); err != nil { @@ -222,24 +225,63 @@ func runBootstrap(cmd *cobra.Command, args []string) error { // Final success message if !flags.Quiet { - if len(plan.ManagedDriftAIs) > 0 { - fmt.Printf("managed_drift_fixed: %s\n", strings.Join(plan.ManagedDriftAIs, ", ")) - } - if len(plan.UnmanagedDriftAIs) > 0 { - fmt.Printf("unmanaged_drift_detected: %s\n", strings.Join(plan.UnmanagedDriftAIs, ", ")) - fmt.Println(" ↳ Run `taskwing doctor --fix --adopt-unmanaged` to claim and repair unmanaged TaskWing-like configs.") - } - if len(plan.GlobalMCPDriftAIs) > 0 { - fmt.Printf("global_mcp_drift_detected: %s\n", strings.Join(plan.GlobalMCPDriftAIs, ", ")) - fmt.Println(" ↳ Run `taskwing doctor --fix` to repair global MCP registration.") - } fmt.Println() fmt.Println("✅ Bootstrap complete!") + printPostBootstrapSummary() } return nil } +// printPostBootstrapSummary shows a compact knowledge summary after bootstrap +// so users immediately see what was extracted from their codebase. +func printPostBootstrapSummary() { + repo, err := openRepo() + if err != nil { + return // non-fatal: skip summary if repo can't be opened + } + defer repo.Close() + + nodes, err := repo.ListNodes("") + if err != nil || len(nodes) == 0 { + return + } + + // Count by type using human-readable labels + byType := make(map[string]int) + for _, n := range nodes { + t := n.Type + if t == "" { + t = "unknown" + } + byType[t]++ + } + + // Build stats with spelled-out type names + var stats []string + typeLabels := map[string]string{ + "decision": "decisions", "feature": "features", "constraint": "constraints", + "pattern": "patterns", "plan": "plans", "note": "notes", + "metadata": "metadata", "documentation": "docs", + } + for _, t := range memory.AllNodeTypes() { + if count := byType[t]; count > 0 { + label := typeLabels[t] + if label == "" { + label = t + } + if count == 1 { + // Singularize + label = strings.TrimSuffix(label, "s") + } + stats = append(stats, fmt.Sprintf("%d %s", count, label)) + } + } + + fmt.Printf("\n Knowledge: %d nodes (%s)\n", len(nodes), strings.Join(stats, ", ")) + fmt.Println(" Run 'taskwing knowledge' to explore, or use the ask MCP tool in your AI tool.") +} + // executeAction executes a single bootstrap action. func executeAction(ctx context.Context, action bootstrap.Action, svc *bootstrap.Service, cwd string, flags bootstrap.Flags, plan *bootstrap.Plan, llmCfg llm.Config) error { switch action { @@ -370,17 +412,13 @@ func executeGenerateAIConfigs(svc *bootstrap.Service, flags bootstrap.Flags, pla return nil } - // Generate configs for each target AI - if !flags.Quiet { - fmt.Printf("🔧 Regenerating AI configurations for: %s\n", strings.Join(targetAIs, ", ")) - } - + // Generate configs (plan summary already showed which AIs are being updated) if err := svc.RegenerateAIConfigs(flags.Verbose, targetAIs); err != nil { return fmt.Errorf("regenerate AI configs failed: %w", err) } if !flags.Quiet { - fmt.Println("✓ AI configurations regenerated") + fmt.Printf("✓ AI configurations updated: %s\n", strings.Join(targetAIs, ", ")) } return nil } @@ -515,7 +553,7 @@ func runAgentTUI(ctx context.Context, svc *bootstrap.Service, cwd string, llmCfg ui.RenderPageHeader("TaskWing Bootstrap", fmt.Sprintf("Using: %s (%s)", llmCfg.Model, llmCfg.Provider)) projectName := filepath.Base(cwd) - allAgents := bootstrap.NewDefaultAgents(llmCfg, cwd) + allAgents := bootstrap.NewDefaultAgents(llmCfg, cwd, nil) defer core.CloseAgents(allAgents) // Open repository for checkpoint tracking @@ -672,9 +710,11 @@ func runMultiRepoBootstrap(ctx context.Context, svc *bootstrap.Service, ws *proj fmt.Println("") ui.RenderPageHeader("TaskWing Multi-Repo Bootstrap", fmt.Sprintf("Workspace: %s | Services: %d", ws.Name, ws.ServiceCount())) - fmt.Printf("📦 Analyzing %d services. Running parallel analysis...\n", ws.ServiceCount()) + fmt.Printf("📦 Analyzing %d services...\n", ws.ServiceCount()) - findings, relationships, errs, err := svc.RunMultiRepoAnalysis(ctx, ws) + findings, relationships, errs, err := svc.RunMultiRepoAnalysis(ctx, ws, func(name, status string) { + fmt.Printf(" %s: %s\n", name, status) + }) if err != nil { return err } @@ -686,14 +726,18 @@ func runMultiRepoBootstrap(ctx context.Context, svc *bootstrap.Service, ws *proj } } - fmt.Printf("📊 Aggregated: %d findings from %d services\n", len(findings), ws.ServiceCount()-len(errs)) - if preview { - fmt.Println("\n💡 This was a preview. Run 'taskwing bootstrap' to save to memory.") + fmt.Printf("\n📊 Preview: %d findings from %d services\n", len(findings), ws.ServiceCount()-len(errs)) + fmt.Println("💡 This was a preview. Run 'taskwing bootstrap' to save to memory.") return nil } - return svc.IngestDirectly(ctx, findings, relationships, viper.GetBool("quiet")) + if err := svc.IngestDirectly(ctx, findings, relationships, viper.GetBool("quiet")); err != nil { + return err + } + + // Don't print completion here -- runBootstrap prints it after all actions finish. + return nil } // promptRepoSelection prompts the user to select which repositories to bootstrap. diff --git a/cmd/doctor.go b/cmd/doctor.go index a84fe26..aedfd58 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -446,7 +446,7 @@ func checkActivePlan() DoctorCheck { Name: "Active Plan", Status: "warn", Message: "No active plan", - Hint: "Run: taskwing goal \"your goal\"", + Hint: "Use /taskwing:plan in your AI tool to create a plan", } } @@ -516,8 +516,8 @@ func printNextSteps(checks []DoctorCheck) { fmt.Println() fmt.Println("Next steps:") if !hasActivePlan { - fmt.Println(" 1. Create and activate plan: taskwing goal \"your development goal\"") - fmt.Println(" 2. Open Claude Code and run: /taskwing:next") + fmt.Println(" 1. Open your AI tool and use /taskwing:plan to create a plan") + fmt.Println(" 2. Run /taskwing:next to start the first task") } else if !hasSession { fmt.Println(" 1. Open Claude Code (session will auto-initialize)") fmt.Println(" 2. Run: /taskwing:next") diff --git a/cmd/goal.go b/cmd/goal.go deleted file mode 100644 index 0fcdd15..0000000 --- a/cmd/goal.go +++ /dev/null @@ -1,209 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "errors" - "fmt" - "os" - "strings" - "time" - - "github.com/josephgoksu/TaskWing/internal/app" - "github.com/josephgoksu/TaskWing/internal/llm" - "github.com/spf13/cobra" - "golang.org/x/term" -) - -var goalCmd = &cobra.Command{ - Use: "goal \"Goal Description\"", - Short: "Turn a goal into an active execution plan", - Long: `Create and activate a plan from a goal in one command. - -This command runs clarification and plan generation automatically, then prints -the next action to start execution in your AI tool.`, - Args: cobra.ExactArgs(1), - RunE: runGoal, -} - -func init() { - rootCmd.AddCommand(goalCmd) - goalCmd.Flags().Bool("auto-answer", false, "Automatically answer clarification questions using project context") - goalCmd.Flags().Int("max-rounds", 5, "Maximum clarify rounds before stopping") -} - -func runGoal(cmd *cobra.Command, args []string) error { - goal := args[0] - autoAnswer, _ := cmd.Flags().GetBool("auto-answer") - maxRounds, _ := cmd.Flags().GetInt("max-rounds") - if maxRounds <= 0 { - maxRounds = 5 - } - - repo, err := openRepoOrHandleMissingMemory() - if err != nil { - return err - } - if repo == nil { - return nil - } - defer func() { _ = repo.Close() }() - - cfg, err := getLLMConfigForRole(cmd, llm.RoleBootstrap) - if err != nil { - return fmt.Errorf("llm config: %w", err) - } - - planApp := app.NewPlanApp(app.NewContextWithConfig(repo, cfg)) - - interactive := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) && !isJSON() - - var clarifyRes *app.ClarifyResult - if autoAnswer || !interactive { - clarifyCtx, clarifyCancel := context.WithTimeout(cmd.Context(), 2*time.Minute) - defer clarifyCancel() - - clarifyRes, err = planApp.Clarify(clarifyCtx, app.ClarifyOptions{ - Goal: goal, - AutoAnswer: autoAnswer, - MaxRounds: maxRounds, - }) - if err != nil { - if errors.Is(clarifyCtx.Err(), context.DeadlineExceeded) { - return fmt.Errorf("clarification timed out after 2 minutes") - } - return fmt.Errorf("clarification failed: %w", err) - } - } else { - reader := bufio.NewReader(os.Stdin) - clarifyRes, err = runInteractiveGoalClarifyLoop(cmd.Context(), reader, planApp, goal, maxRounds) - if err != nil { - return err - } - } - - if clarifyRes == nil { - return fmt.Errorf("clarification failed: empty result") - } - if !clarifyRes.Success { - return fmt.Errorf("clarification failed: %s", clarifyRes.Message) - } - if !clarifyRes.IsReadyToPlan { - if isJSON() { - return printJSON(map[string]any{ - "success": false, - "clarify_session_id": clarifyRes.ClarifySessionID, - "is_ready_to_plan": false, - "questions": clarifyRes.Questions, - "enriched_goal": clarifyRes.EnrichedGoal, - "round_index": clarifyRes.RoundIndex, - "max_rounds_reached": clarifyRes.MaxRoundsReached, - "message": "clarification is unresolved; answer questions and retry clarify", - }) - } - fmt.Printf("Clarification unresolved (session: %s, round: %d)\n", clarifyRes.ClarifySessionID, clarifyRes.RoundIndex) - if len(clarifyRes.Questions) > 0 { - fmt.Println("Pending questions:") - for i, q := range clarifyRes.Questions { - fmt.Printf(" %d. %s\n", i+1, q) - } - } - fmt.Println("No plan generated. Continue clarification first.") - return nil - } - - enrichedGoal := strings.TrimSpace(clarifyRes.EnrichedGoal) - if enrichedGoal == "" { - enrichedGoal = goal - } - - genCtx, genCancel := context.WithTimeout(cmd.Context(), 2*time.Minute) - defer genCancel() - - genRes, err := planApp.Generate(genCtx, app.GenerateOptions{ - Goal: goal, - ClarifySessionID: clarifyRes.ClarifySessionID, - EnrichedGoal: enrichedGoal, - Save: true, - }) - if err != nil { - if errors.Is(genCtx.Err(), context.DeadlineExceeded) { - return fmt.Errorf("plan generation timed out after 2 minutes") - } - return fmt.Errorf("plan generation failed: %w", err) - } - if !genRes.Success { - return fmt.Errorf("plan generation failed: %s", genRes.Message) - } - - if isJSON() { - return printJSON(map[string]any{ - "success": true, - "clarify_session_id": clarifyRes.ClarifySessionID, - "plan_id": genRes.PlanID, - "task_count": len(genRes.Tasks), - "next_steps": []string{ - "taskwing slash next", - "or call MCP tool task with action=next", - }, - }) - } - - fmt.Printf("Plan created and activated: %s (%d task(s))\n", genRes.PlanID, len(genRes.Tasks)) - fmt.Println("Next:") - fmt.Println(" 1. In your AI tool, run /taskwing:next") - fmt.Println(" 2. Or use MCP tool task with action=next") - return nil -} - -func runInteractiveGoalClarifyLoop(ctx context.Context, reader *bufio.Reader, planApp *app.PlanApp, goal string, maxRounds int) (*app.ClarifyResult, error) { - clarifyCtx, clarifyCancel := context.WithTimeout(ctx, 2*time.Minute) - defer clarifyCancel() - result, err := planApp.Clarify(clarifyCtx, app.ClarifyOptions{ - Goal: goal, - MaxRounds: maxRounds, - }) - if err != nil { - if errors.Is(clarifyCtx.Err(), context.DeadlineExceeded) { - return nil, fmt.Errorf("clarification timed out after 2 minutes") - } - return nil, fmt.Errorf("clarification failed: %w", err) - } - - for result != nil && result.Success && !result.IsReadyToPlan && !result.MaxRoundsReached { - if len(result.Questions) == 0 { - return result, nil - } - fmt.Printf("Clarify round %d (%s):\n", result.RoundIndex, result.ClarifySessionID) - answers := make([]app.ClarifyAnswer, 0, len(result.Questions)) - for i, q := range result.Questions { - fmt.Printf(" %d. %s\n", i+1, q) - fmt.Print(" answer> ") - line, readErr := reader.ReadString('\n') - if readErr != nil { - return nil, fmt.Errorf("read answer: %w", readErr) - } - answers = append(answers, app.ClarifyAnswer{ - Question: q, - Answer: strings.TrimSpace(line), - }) - } - - nextCtx, nextCancel := context.WithTimeout(ctx, 2*time.Minute) - result, err = planApp.Clarify(nextCtx, app.ClarifyOptions{ - Goal: goal, - ClarifySessionID: result.ClarifySessionID, - Answers: answers, - MaxRounds: maxRounds, - }) - nextCancel() - if err != nil { - if errors.Is(nextCtx.Err(), context.DeadlineExceeded) { - return nil, fmt.Errorf("clarification timed out after 2 minutes") - } - return nil, fmt.Errorf("clarification failed: %w", err) - } - } - - return result, nil -} diff --git a/cmd/hook.go b/cmd/hook.go index f529ccc..8e1a4cc 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -245,17 +245,33 @@ func runContinueCheck(maxTasks, maxMinutes int) error { activePlan, err := repo.GetActivePlan() if err != nil || activePlan == nil { return outputHookResponse(HookResponse{ - Reason: "No active plan. Use 'taskwing plan start ' to set one.", + Reason: "No active plan. Use /taskwing:plan to create one.", }) } - // Check current task status + // Sync TasksCompleted from DB (source of truth) instead of trusting session JSON. + // This fixes the race where session save fails but task completion succeeds. + completedCount := 0 + for _, t := range activePlan.Tasks { + if t.Status == task.StatusCompleted { + completedCount++ + } + } + if completedCount > session.TasksCompleted { + session.TasksCompleted = completedCount + } + + // Load current task from DB to verify status (don't trust session cache) var currentTask *task.Task if session.CurrentTaskID != "" { - var err error currentTask, err = repo.GetTask(session.CurrentTaskID) - if err != nil && viper.GetBool("verbose") { - fmt.Fprintf(os.Stderr, "[DEBUG] Could not load current task %s: %v\n", session.CurrentTaskID, err) + if err != nil { + // Task not found or DB error -- clear stale reference + staleID := session.CurrentTaskID + session.CurrentTaskID = "" + if viper.GetBool("verbose") { + fmt.Fprintf(os.Stderr, "[DEBUG] Could not load current task %s: %v\n", staleID, err) + } } } @@ -269,7 +285,6 @@ func runContinueCheck(maxTasks, maxMinutes int) error { // No more tasks = plan complete if nextTask == nil { - // Check if all tasks are done allDone := true for _, t := range activePlan.Tasks { if t.Status != task.StatusCompleted { @@ -290,28 +305,20 @@ func runContinueCheck(maxTasks, maxMinutes int) error { // Build context for next task contextStr := buildTaskContext(repo, nextTask, activePlan) - // Update session state + // Run Sentinel + policy analysis on completed task (if just completed) if currentTask != nil && currentTask.Status == task.StatusCompleted { - session.TasksCompleted++ - - // Run Sentinel analysis with git verification on the just-completed task - // Git verification catches cases where an agent lies about what files it modified workDir, _ := os.Getwd() sentinel := task.NewSentinel() report := sentinel.AnalyzeWithVerification(context.Background(), currentTask, workDir) - // Track deviations in session if report.HasDeviations() { session.TotalDeviationsDetected += len(report.Deviations) session.LastDeviationSummary = report.Summary - - // Set critical flag for next continue-check to handle if report.HasCriticalDeviations() { session.LastTaskHadCriticalDeviation = true } } - // Run policy evaluation on completed task policyResult := evaluateTaskPolicy(context.Background(), currentTask, activePlan.Goal, session.SessionID, workDir) if policyResult != nil && !policyResult.Allowed { session.TotalPolicyViolations += len(policyResult.Violations) @@ -319,12 +326,19 @@ func runContinueCheck(maxTasks, maxMinutes int) error { session.LastTaskHadPolicyViolation = true } } + + // Update session state session.CurrentTaskID = nextTask.ID session.PlanID = activePlan.ID session.TasksStarted++ + + // Save session with retry -- session sync failure is the #1 cause of hook unreliability if err := saveHookSession(session); err != nil { - // Log to stderr but don't fail - hook must return valid JSON - fmt.Fprintf(os.Stderr, "[WARN] Failed to save session state: %v\n", err) + fmt.Fprintf(os.Stderr, "[WARN] Session save failed, retrying: %v\n", err) + time.Sleep(100 * time.Millisecond) + if retryErr := saveHookSession(session); retryErr != nil { + fmt.Fprintf(os.Stderr, "[WARN] Session save retry also failed: %v\n", retryErr) + } } // Return block with next task context in reason field @@ -337,34 +351,48 @@ func runContinueCheck(maxTasks, maxMinutes int) error { } // buildTaskContext creates the context string to inject for the next task. -// Delegates to task.FormatRichContext for consistent presentation across CLI and MCP. +// Uses the unified GetProjectContext API for retrieval. func buildTaskContext(repo *memory.Repository, nextTask *task.Task, plan *task.Plan) string { ctx := context.Background() - // Get knowledge service for ask context llmCfg, _ := getLLMConfigFromViper() ks := knowledge.NewService(repo, llmCfg) - // Create search adapter that wraps knowledge.Service for the task package - searchFn := func(ctx context.Context, query string, limit int) ([]task.AskResult, error) { - searchCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - results, err := ks.Search(searchCtx, query, limit) - if err != nil { - return nil, err - } - var adapted []task.AskResult - for _, r := range results { - adapted = append(adapted, task.AskResult{ - Summary: r.Node.Summary, - Type: r.Node.Type, - Content: r.Node.Text(), + opts := knowledge.DefaultContextOptions() + opts.Query = nextTask.Title + " " + nextTask.Description + opts.IncludeArchitectureMD = false // Too large for hook injection + opts.MaxNodes = 8 + opts.UseLLMQueries = false // Speed: use task title directly + + memoryPath, _ := config.GetMemoryBasePath() + opts.MemoryBasePath = memoryPath + + pc, err := knowledge.GetProjectContext(ctx, ks, opts) + if err != nil { + return task.FormatRichContext(ctx, nextTask, plan, nil) + } + + // Combine unified context with task-specific formatting + projectCtx := pc.FormatCompact() + + // Create search adapter backed by the already-retrieved nodes + searchFn := func(_ context.Context, _ string, _ int) ([]task.AskResult, error) { + var results []task.AskResult + for _, sn := range pc.RelevantNodes { + results = append(results, task.AskResult{ + Summary: sn.Node.Summary, + Type: sn.Node.Type, + Content: sn.Node.Text(), }) } - return adapted, nil + return results, nil } - return task.FormatRichContext(ctx, nextTask, plan, searchFn) + richCtx := task.FormatRichContext(ctx, nextTask, plan, searchFn) + if projectCtx != "" { + return projectCtx + "\n\n" + richCtx + } + return richCtx } // runSessionInit initializes a new hook session @@ -401,7 +429,7 @@ func runSessionInit() error { // Note: Circuit breaker values shown are defaults; actual values depend on hook config planInfo := session.PlanID if planInfo == "" { - planInfo = "(none - use 'taskwing plan start ' to set)" + planInfo = "(none - use /taskwing:plan to create one)" } fmt.Printf(`TaskWing Session Initialized diff --git a/cmd/knowledge.go b/cmd/knowledge.go index 2cab7f4..693105d 100644 --- a/cmd/knowledge.go +++ b/cmd/knowledge.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/josephgoksu/TaskWing/internal/app" + "github.com/josephgoksu/TaskWing/internal/config" "github.com/josephgoksu/TaskWing/internal/memory" "github.com/josephgoksu/TaskWing/internal/ui" "github.com/spf13/cobra" @@ -114,10 +115,11 @@ func runKnowledge(cmd *cobra.Command, args []string) error { return nil } + basePath, _ := config.GetProjectRoot() if viper.GetBool("verbose") { - ui.RenderNodeListVerbose(nodes) + ui.RenderNodeListVerbose(nodes, basePath) } else { - ui.RenderNodeList(nodes) + ui.RenderNodeList(nodes, basePath) } if !isQuiet() { diff --git a/cmd/memory_export.go b/cmd/memory_export.go index 5668fa5..7895631 100644 --- a/cmd/memory_export.go +++ b/cmd/memory_export.go @@ -21,8 +21,8 @@ type trainingMessage struct { } type trainingExample struct { - Messages []trainingMessage `json:"messages"` - Metadata map[string]any `json:"metadata,omitempty"` + Messages []trainingMessage `json:"messages"` + Metadata map[string]any `json:"metadata,omitempty"` } // Classification system prompt (matches generate_classification_data.py) @@ -53,7 +53,7 @@ Examples: RunE: runExportTraining, } -func runExportTraining(cmd *cobra.Command, args []string) error { +func runExportTraining(cmd *cobra.Command, args []string) (err error) { memoryPath, err := config.GetMemoryBasePath() if err != nil { return fmt.Errorf("get memory path: %w", err) @@ -78,7 +78,11 @@ func runExportTraining(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("create output file: %w", err) } - defer out.Close() + defer func() { + if cerr := out.Close(); cerr != nil && err == nil { + err = fmt.Errorf("close output file: %w", cerr) + } + }() } else { out = os.Stdout } diff --git a/cmd/plan.go b/cmd/plan.go deleted file mode 100644 index 30e3274..0000000 --- a/cmd/plan.go +++ /dev/null @@ -1,693 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - "os" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/josephgoksu/TaskWing/internal/agents/core" - "github.com/josephgoksu/TaskWing/internal/app" - "github.com/josephgoksu/TaskWing/internal/config" - "github.com/josephgoksu/TaskWing/internal/knowledge" - "github.com/josephgoksu/TaskWing/internal/llm" - "github.com/josephgoksu/TaskWing/internal/logger" - "github.com/josephgoksu/TaskWing/internal/task" - "github.com/josephgoksu/TaskWing/internal/ui" - "github.com/josephgoksu/TaskWing/internal/util" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "golang.org/x/term" -) - -func init() { - rootCmd.AddCommand(planCmd) - planCmd.AddCommand(planNewCmd) - planCmd.AddCommand(planListCmd) - planCmd.AddCommand(planExportCmd) - planCmd.AddCommand(planShowCmd) - planCmd.AddCommand(planDeleteCmd) - planCmd.AddCommand(planUpdateCmd) - planCmd.AddCommand(planRenameCmd) - planCmd.AddCommand(planArchiveCmd) - planCmd.AddCommand(planUnarchiveCmd) - planCmd.AddCommand(planStartCmd) - planCmd.AddCommand(planStatusCmd) - - // Flags - planNewCmd.Flags().Bool("no-export", false, "Skip automatic export") - planNewCmd.Flags().String("export-path", "", "Custom path to export plan") - planNewCmd.Flags().Bool("non-interactive", false, "Run without user interaction (headless)") - planNewCmd.Flags().Bool("offline", false, "Disable LLM usage (create a draft plan without tasks)") - planNewCmd.Flags().Bool("no-llm", false, "Alias for --offline") - - planExportCmd.Flags().Bool("stdout", false, "Print to stdout") - planExportCmd.Flags().StringP("output", "o", "", "Custom output path") - - planDeleteCmd.Flags().Bool("force", false, "Skip confirmation") - - planUpdateCmd.Flags().String("goal", "", "Update goal") - planUpdateCmd.Flags().String("enriched-goal", "", "Update enriched goal") - planUpdateCmd.Flags().String("status", "", "Update status") - - // List flags - planListCmd.Flags().StringP("query", "q", "", "Filter by goal/enriched goal") - planListCmd.Flags().StringP("status", "s", "", "Filter by status (draft, active, completed, verified, needs_revision, archived)") -} - -// Wrapper to handle repo lifecycle automatically -func runWithService(runFunc func(svc *task.Service, cmd *cobra.Command, args []string) error) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - repo, err := openRepoOrHandleMissingMemory() - if err != nil { - return err - } - if repo == nil { - return nil - } - defer func() { _ = repo.Close() }() - - memoryPath, err := config.GetMemoryBasePath() - if err != nil { - return fmt.Errorf("get memory path: %w", err) - } - svc := task.NewService(repo, memoryPath) - return runFunc(svc, cmd, args) - } -} - -var planCmd = &cobra.Command{ - Use: "plan", - Short: "Manage development plans", - Long: `Create, view, and export development plans using AI agents. - -Examples: - taskwing goal "Add OAuth2 authentication" - taskwing plan list - taskwing plan export latest - taskwing plan start latest`, -} - -var planNewCmd = &cobra.Command{ - Use: "new \"Goal Description\"", - Short: "Create a new plan from a goal", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := context.Background() - goal := args[0] - - // Track user input for crash logging - logger.SetLastInput(fmt.Sprintf("plan new %q", goal)) - - repo, err := openRepoOrHandleMissingMemory() - if err != nil { - return err - } - if repo == nil { - return nil - } - defer func() { _ = repo.Close() }() - - memoryPath, err := config.GetMemoryBasePath() - if err != nil { - return fmt.Errorf("get memory path: %w", err) - } - svc := task.NewService(repo, memoryPath) - - offline, _ := cmd.Flags().GetBool("offline") - noLLM, _ := cmd.Flags().GetBool("no-llm") - if noLLM { - offline = true - } - if offline { - if !isQuiet() && !isJSON() { - fmt.Fprintln(os.Stderr, "⚠️ Offline mode: LLM disabled. Creating a draft plan without tasks.") - } - draft := &task.Plan{ - Goal: goal, - EnrichedGoal: goal, - Status: task.PlanStatusDraft, - Tasks: []task.Task{}, - } - if err := repo.CreatePlan(draft); err != nil { - return fmt.Errorf("create draft plan: %w", err) - } - createdPlan, err := svc.GetPlanWithTasks(draft.ID) - if err != nil { - return fmt.Errorf("fetch created plan: %w", err) - } - fmt.Println() - printPlanView(createdPlan) - - noExport, _ := cmd.Flags().GetBool("no-export") - exportPath, _ := cmd.Flags().GetString("export-path") - if !noExport && !viper.GetBool("preview") { - outputPath, err := svc.ExportPlanToFile(createdPlan, exportPath) - if err != nil { - return fmt.Errorf("export plan: %w", err) - } - if !isQuiet() && !isJSON() { - fmt.Printf("\nSaved: %s\n", outputPath) - } - } - return nil - } - - cfg, err := getLLMConfigForRole(cmd, llm.RoleBootstrap) - if err != nil { - return fmt.Errorf("llm config: %w", err) - } - - // Initialize App Layer - // Agents are now managed internally by PlanApp methods - appCtx := app.NewContextWithConfig(repo, cfg) - planApp := app.NewPlanApp(appCtx) - - nonInteractive, _ := cmd.Flags().GetBool("non-interactive") - if !nonInteractive && !hasTTY() { - nonInteractive = true - if !isQuiet() && !isJSON() { - fmt.Fprintln(os.Stderr, "⚠️ No TTY detected; falling back to --non-interactive") - } - } - if nonInteractive { - // Headless Flow - fmt.Printf("Analyzing goal: %q...\n", goal) - - clarifyCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - clarifyRes, err := planApp.Clarify(clarifyCtx, app.ClarifyOptions{ - Goal: goal, - AutoAnswer: true, - MaxRounds: 5, - }) - if err != nil { - if errors.Is(clarifyCtx.Err(), context.DeadlineExceeded) { - return fmt.Errorf("clarification timed out after 2 minutes") - } - return fmt.Errorf("clarification error: %w", err) - } - if !clarifyRes.Success { - return fmt.Errorf("clarification failed: %s", clarifyRes.Message) - } - if !clarifyRes.IsReadyToPlan { - return fmt.Errorf("clarification unresolved after %d round(s); answer questions and retry", clarifyRes.RoundIndex) - } - fmt.Printf("Goal refined: %s\nGenerating plan...\n", clarifyRes.GoalSummary) - - genCtx, genCancel := context.WithTimeout(ctx, 2*time.Minute) - defer genCancel() - genRes, err := planApp.Generate(genCtx, app.GenerateOptions{ - Goal: goal, - ClarifySessionID: clarifyRes.ClarifySessionID, - EnrichedGoal: clarifyRes.EnrichedGoal, - Save: true, - }) - if err != nil { - if errors.Is(genCtx.Err(), context.DeadlineExceeded) { - return fmt.Errorf("plan generation timed out after 2 minutes") - } - return fmt.Errorf("generation error: %w", err) - } - if !genRes.Success { - return fmt.Errorf("generation failed: %s", genRes.Message) - } - - // Reuse success logic - createdPlan, err := svc.GetPlanWithTasks(genRes.PlanID) - if err != nil { - return fmt.Errorf("fetch created plan: %w", err) - } - fmt.Println() - printPlanView(createdPlan) - - if !isQuiet() && !isJSON() { - if len(genRes.SemanticWarnings) > 0 || len(genRes.SemanticErrors) > 0 { - fmt.Println() - fmt.Printf("Semantic validation (non-blocking): %d warning(s), %d error(s)\n", - len(genRes.SemanticWarnings), len(genRes.SemanticErrors)) - for _, w := range genRes.SemanticWarnings { - fmt.Printf(" ⚠ %s\n", w) - } - for _, e := range genRes.SemanticErrors { - fmt.Printf(" ⚠ %s\n", e) - } - } - } - - // Export logic - noExport, _ := cmd.Flags().GetBool("no-export") - exportPath, _ := cmd.Flags().GetString("export-path") - if !noExport && !viper.GetBool("preview") { - outputPath, err := svc.ExportPlanToFile(createdPlan, exportPath) - if err != nil { - return fmt.Errorf("export plan: %w", err) - } - if !isQuiet() && !isJSON() { - fmt.Printf("\nSaved: %s\n", outputPath) - } - } - return nil - } - - ks := knowledge.NewService(repo, cfg) - - stream := core.NewStreamingOutput(100) - defer stream.Close() - - model := ui.NewPlanModel( - ctx, - goal, - planApp, - ks, - repo, - stream, - memoryPath, - ) - - p := tea.NewProgram(model) - finalModel, err := p.Run() - if err != nil { - return fmt.Errorf("tui error: %w", err) - } - - m, ok := finalModel.(ui.PlanModel) - if !ok || m.State == ui.StateError { - if m.State == ui.StateError { - return m.Err - } - return fmt.Errorf("internal error: invalid model type") - } - - if m.State == ui.StateSuccess && m.PlanID != "" { - createdPlan, err := svc.GetPlanWithTasks(m.PlanID) - if err != nil { - return fmt.Errorf("fetch created plan: %w", err) - } - - fmt.Println() - printPlanView(createdPlan) - - noExport, _ := cmd.Flags().GetBool("no-export") - exportPath, _ := cmd.Flags().GetString("export-path") - if !noExport && !viper.GetBool("preview") { - outputPath, err := svc.ExportPlanToFile(createdPlan, exportPath) - if err != nil { - return fmt.Errorf("export plan: %w", err) - } - if !isQuiet() && !isJSON() { - fmt.Printf("\nSaved: %s\n", outputPath) - } - } - } - - return nil - }, -} - -func hasTTY() bool { - return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) -} - -var planListCmd = &cobra.Command{ - Use: "list", - Short: "List all plans", - RunE: runWithService(func(svc *task.Service, cmd *cobra.Command, args []string) error { - query, _ := cmd.Flags().GetString("query") - statusStr, _ := cmd.Flags().GetString("status") - - // If using positional args for query - if len(args) > 0 { - query = strings.Join(args, " ") - } - - var plans []task.Plan - var err error - - if query != "" || statusStr != "" { - plans, err = svc.SearchPlans(query, task.PlanStatus(statusStr)) - } else { - plans, err = svc.ListPlans() - } - - if err != nil { - return err - } - - if isJSON() { - return printJSON(plans) - } - - if len(plans) == 0 { - fmt.Println("No plans found matching criteria.") - return nil - } - - ui.RenderPageHeader("TaskWing Plan List", "") - printPlanTable(plans) - return nil - }), -} - -func printPlanTable(plans []task.Plan) { - headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Bold(true) - idStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("75")) - dateStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - goalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255")) - - fmt.Printf("%-18s %-12s %-6s %s\n", - headerStyle.Render("ID"), headerStyle.Render("CREATED"), headerStyle.Render("TASKS"), headerStyle.Render("GOAL")) - - for _, p := range plans { - goal := p.Goal - if len(goal) > 60 { - goal = goal[:57] + "..." - } - // Tasks count - service ListPlans probably returns plans without tasks or with? - // ListPlans sets TaskCount but leaves Tasks nil for efficiency. - // Use GetTaskCount() to get the count regardless of how the plan was loaded. - fmt.Printf("%-18s %-12s %-6d %s\n", - idStyle.Render(p.ID), - dateStyle.Render(p.CreatedAt.Format("2006-01-02")), - p.GetTaskCount(), - goalStyle.Render(goal)) - } - fmt.Printf("\n%s\n", lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(fmt.Sprintf("Total: %d plan(s)", len(plans)))) -} - -var planExportCmd = &cobra.Command{ - Use: "export [plan-id]", - Short: "Export plan to Markdown", - Args: cobra.ExactArgs(1), - RunE: runWithService(func(svc *task.Service, cmd *cobra.Command, args []string) error { - plan, err := svc.GetPlanWithTasks(args[0]) - if err != nil { - return err - } - - toStdout, _ := cmd.Flags().GetBool("stdout") - if toStdout { - fmt.Print(svc.FormatPlanMarkdown(plan)) - return nil - } - - customOutput, _ := cmd.Flags().GetString("output") - outputPath, err := svc.ExportPlanToFile(plan, customOutput) - if err != nil { - return err - } - - fmt.Printf("\n✓ Plan exported to %s\n", outputPath) - return nil - }), -} - -var planShowCmd = &cobra.Command{ - Use: "show [plan-id]", - Short: "Show a plan in the terminal", - Args: cobra.ExactArgs(1), - RunE: runWithService(func(svc *task.Service, cmd *cobra.Command, args []string) error { - plan, err := svc.GetPlanWithTasks(args[0]) - if err != nil { - return err - } - printPlanView(plan) - return nil - }), -} - -var planDeleteCmd = &cobra.Command{ - Use: "delete [plan-id]", - Short: "Delete a plan and its tasks", - Args: cobra.ExactArgs(1), - RunE: runWithService(func(svc *task.Service, cmd *cobra.Command, args []string) error { - planID := args[0] - force, _ := cmd.Flags().GetBool("force") - - if !force && !isJSON() { - plan, err := svc.GetPlan(planID) - if err == nil { - fmt.Printf("Delete plan %s: \"%s\"? [y/N]: ", plan.ID, plan.Goal) - if !confirmOrAbort("") { - return nil - } - } - } - - if err := svc.DeletePlan(planID); err != nil { - return err - } - - if !isQuiet() && !isJSON() { - fmt.Printf("✓ Deleted plan %s\n", planID) - } - return nil - }), -} - -var planUpdateCmd = &cobra.Command{ - Use: "update [plan-id]", - Short: "Update a plan", - Args: cobra.ExactArgs(1), - RunE: runWithService(func(svc *task.Service, cmd *cobra.Command, args []string) error { - goal, _ := cmd.Flags().GetString("goal") - enrichedGoal, _ := cmd.Flags().GetString("enriched-goal") - statusStr, _ := cmd.Flags().GetString("status") - - var status task.PlanStatus - if statusStr != "" { - status = task.PlanStatus(statusStr) - } - - if err := svc.UpdatePlan(args[0], goal, enrichedGoal, status); err != nil { - return err - } - - if !isQuiet() && !isJSON() { - fmt.Printf("✓ Updated plan %s\n", args[0]) - } - return nil - }), -} - -var planRenameCmd = &cobra.Command{ - Use: "rename [plan-id] \"new goal\"", - Short: "Rename a plan goal", - Args: cobra.ExactArgs(2), - RunE: runWithService(func(svc *task.Service, cmd *cobra.Command, args []string) error { - if err := svc.RenamePlan(args[0], args[1]); err != nil { - return err - } - if !isQuiet() { - fmt.Printf("✓ Renamed plan %s\n", args[0]) - } - return nil - }), -} - -var planArchiveCmd = &cobra.Command{ - Use: "archive [plan-id]", - Short: "Archive a plan", - Args: cobra.ExactArgs(1), - RunE: runWithService(func(svc *task.Service, cmd *cobra.Command, args []string) error { - if err := svc.ArchivePlan(args[0]); err != nil { - return err - } - if !isQuiet() { - fmt.Printf("✓ Archived plan %s\n", args[0]) - } - return nil - }), -} - -var planUnarchiveCmd = &cobra.Command{ - Use: "unarchive [plan-id]", - Short: "Unarchive a plan", - Args: cobra.ExactArgs(1), - RunE: runWithService(func(svc *task.Service, cmd *cobra.Command, args []string) error { - if err := svc.UnarchivePlan(args[0]); err != nil { - return err - } - if !isQuiet() { - fmt.Printf("✓ Unarchived plan %s\n", args[0]) - } - return nil - }), -} - -var planStartCmd = &cobra.Command{ - Use: "start [plan-id]", - Short: "Set a plan as the active working plan", - Args: cobra.ExactArgs(1), - RunE: runWithService(func(svc *task.Service, cmd *cobra.Command, args []string) error { - if err := svc.SetActivePlan(args[0]); err != nil { - return err - } - - plan, _ := svc.GetPlanWithTasks(args[0]) // Get resolved plan details - if !isQuiet() { - fmt.Printf("\n✓ Active plan: %s\n", plan.ID) - fmt.Printf(" Goal: %s\n", plan.Goal) - fmt.Printf(" Tasks: %d\n\n", len(plan.Tasks)) - } - return nil - }), -} - -var planStatusCmd = &cobra.Command{ - Use: "status", - Short: "Show the current active plan and progress", - Long: `Show the status of the current active plan including progress. - -Displays the plan goal, task counts, and completion percentage. - -Examples: - taskwing plan status - taskwing plan status --json`, - RunE: runWithService(func(svc *task.Service, cmd *cobra.Command, args []string) error { - planID, err := svc.GetActivePlanID() - if err != nil || planID == "" { - if isJSON() { - return printJSON(map[string]any{ - "success": false, - "message": "No active plan", - }) - } - fmt.Println("No active plan. Set one with: taskwing goal \"\"") - return nil - } - - plan, err := svc.GetPlanWithTasks(planID) - if err != nil { - _ = svc.ClearActivePlan() - if isJSON() { - return printJSON(map[string]any{ - "success": false, - "message": fmt.Sprintf("Active plan %s no longer exists", planID), - }) - } - fmt.Printf("Active plan %s no longer exists. Cleared.\n", planID) - return nil - } - - if isJSON() { - return printPlanStatusJSON(plan) - } - - printStatus(plan) - return nil - }), -} - -// PlanStatusResponse is the JSON response for plan status -type PlanStatusResponse struct { - Success bool `json:"success"` - PlanID string `json:"plan_id"` - Goal string `json:"goal"` - Status string `json:"status"` - Total int `json:"total_tasks"` - Completed int `json:"completed_tasks"` - Pending int `json:"pending_tasks"` - InProgress int `json:"in_progress_tasks"` - ProgressPct int `json:"progress_percent"` -} - -func printPlanStatusJSON(plan *task.Plan) error { - completed := 0 - inProgress := 0 - pending := 0 - for _, t := range plan.Tasks { - switch t.Status { - case task.StatusCompleted: - completed++ - case task.StatusInProgress: - inProgress++ - default: - pending++ - } - } - - total := len(plan.Tasks) - progressPct := 0 - if total > 0 { - progressPct = completed * 100 / total - } - - return printJSON(PlanStatusResponse{ - Success: true, - PlanID: plan.ID, - Goal: plan.Goal, - Status: string(plan.Status), - Total: total, - Completed: completed, - Pending: pending, - InProgress: inProgress, - ProgressPct: progressPct, - }) -} - -func printStatus(plan *task.Plan) { - done := 0 - total := len(plan.Tasks) - for _, t := range plan.Tasks { - if t.Status == task.StatusCompleted { - done++ - } - } - - progressPct := 0 - if total > 0 { - progressPct = done * 100 / total - } - - fmt.Printf("\n📋 Active Plan: %s\n", plan.ID) - fmt.Printf(" %s\n\n", plan.Goal) - - barWidth := 30 - filled := barWidth * done / max(total, 1) - bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) - - fmt.Printf(" Progress: [%s] %d%% (%d/%d)\n\n", bar, progressPct, done, total) - - passStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42")) - pendingStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")) - dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - - fmt.Println(" Tasks:") - for _, t := range plan.Tasks { - statusMarker := pendingStyle.Render("[ ]") - title := t.Title - - if t.Status == task.StatusCompleted { - statusMarker = passStyle.Render("[✓]") - title = dimStyle.Render(title) - } - - // Use ShortID for consistent task ID display - tid := util.ShortID(t.ID, util.TaskIDLength) - fmt.Printf(" %s %s %s\n", statusMarker, dimStyle.Render(tid), title) - } - fmt.Println() -} - -func printPlanView(plan *task.Plan) { - taskCount := len(plan.Tasks) - fmt.Printf("Plan: %s | %d tasks\n\n", plan.ID, taskCount) - - fmt.Printf("# Plan: %s\n\n", plan.Goal) - fmt.Printf("**Refined Goal**: %s\n\n", plan.EnrichedGoal) - - for _, t := range plan.Tasks { - fmt.Printf("## Task: %s\n", t.Title) - fmt.Printf("**Priority**: %d | **Agent**: %s\n\n", t.Priority, t.AssignedAgent) - fmt.Printf("%s\n\n", t.Description) - } - - fmt.Println("Next steps:") - fmt.Println(" • taskwing task list --plan " + plan.ID) - fmt.Println(" • /taskwing:next") -} diff --git a/cmd/root.go b/cmd/root.go index 4832a13..29f795d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -124,10 +124,10 @@ func initCrashHandler() { // getCommandHint returns a helpful hint for common command mistakes func getCommandHint(cmd string) string { hints := map[string]string{ - "plans": "Hint: To list plans, use: taskwing plan list", + "plans": "Hint: Use /taskwing:plan in your AI tool", "tasks": "Hint: To list tasks, use: taskwing task list", - "create": "Hint: To create and activate a plan, use: taskwing goal \"\"", - "new": "Hint: To create and activate a plan, use: taskwing goal \"\"", + "create": "Hint: Use /taskwing:plan in your AI tool", + "new": "Hint: Use /taskwing:plan in your AI tool", "install": "Hint: To install MCP, use: taskwing mcp install", } @@ -181,10 +181,12 @@ func GetVersion() string { // maybeRunPostUpgradeMigration runs a one-time migration when the CLI version // changes (e.g., after brew upgrade). Skips commands that don't need project context. func maybeRunPostUpgradeMigration(cmd *cobra.Command) { - // Skip the entire mcp subtree (and other commands that don't need migration) + // Skip commands that don't need migration (version, help) + // Note: mcp is NOT skipped -- when Claude Code starts the MCP server after + // a Brew upgrade, we want slash commands silently regenerated. for c := cmd; c != nil; c = c.Parent() { n := c.Name() - if n == "version" || n == "help" || n == "mcp" { + if n == "version" || n == "help" { return } } @@ -376,7 +378,7 @@ func trackCommandExecutionWithError(cmd *cobra.Command, args []string, success b // Calculate duration in milliseconds durationMs := time.Since(commandStartTime).Milliseconds() - // Get full command path (e.g., "plan new", "task list") + // Get full command path (e.g., "task list", "config set") commandPath := getCommandPath(cmd) // Build properties @@ -396,7 +398,7 @@ func trackCommandExecutionWithError(cmd *cobra.Command, args []string, success b telemetryClient.Track("command_executed", props) } -// getCommandPath returns the full command path (e.g., "plan new", "task list"). +// getCommandPath returns the full command path (e.g., "task list", "config set"). func getCommandPath(cmd *cobra.Command) string { if cmd == nil { return "unknown" diff --git a/cmd/slash.go b/cmd/slash.go deleted file mode 100644 index 8c963e8..0000000 --- a/cmd/slash.go +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright © 2025 Joseph Goksu josephgoksu@gmail.com -*/ -package cmd - -import ( - "fmt" - "sort" - "strings" - - "github.com/josephgoksu/TaskWing/internal/bootstrap" - "github.com/spf13/cobra" -) - -var slashContents = map[string]string{ - "next": slashNextContent, - "done": slashDoneContent, - "status": slashStatusContent, - "plan": slashPlanContent, - "ask": slashAskContent, - "remember": slashRememberContent, - "simplify": slashSimplifyContent, - "debug": slashDebugContent, - "explain": slashExplainContent, -} - -var slashCmd = &cobra.Command{ - Use: "slash", - Short: "Output slash command content for AI assistants", - SilenceUsage: true, - Long: `Outputs the full prompt content for slash commands. - -This command is called dynamically by AI assistant slash commands -to ensure the content always matches the installed CLI version. - -Example: - taskwing slash next # Output /taskwing:next content - taskwing slash done # Output /taskwing:done content - taskwing slash plan # Output /taskwing:plan content`, - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return cmd.Help() - } - return fmt.Errorf("unknown slash command %q (available: %s)", args[0], strings.Join(availableSlashCommands(cmd), ", ")) - }, -} - -func availableSlashCommands(cmd *cobra.Command) []string { - available := make([]string, 0, len(cmd.Commands())) - for _, sub := range cmd.Commands() { - if !sub.IsAvailableCommand() || sub.Name() == "help" { - continue - } - available = append(available, sub.Name()) - } - sort.Strings(available) - return available -} - -func init() { - rootCmd.AddCommand(slashCmd) - - for _, slash := range bootstrap.SlashCommands { - content, ok := slashContents[slash.SlashCmd] - if !ok { - continue - } - - short := fmt.Sprintf("Output /%s command content", slash.BaseName) - c := &cobra.Command{ - Use: slash.SlashCmd, - Short: short, - Run: func(content string) func(*cobra.Command, []string) { - return func(cmd *cobra.Command, args []string) { - fmt.Print(content) - } - }(content), - } - - slashCmd.AddCommand(c) - } -} diff --git a/cmd/slash_content.go b/cmd/slash_content.go deleted file mode 100644 index 2fe1556..0000000 --- a/cmd/slash_content.go +++ /dev/null @@ -1,777 +0,0 @@ -/* -Copyright © 2025 Joseph Goksu josephgoksu@gmail.com -*/ -package cmd - -// slashNextContent is the prompt content for /taskwing:next -const slashNextContent = `# Start Next TaskWing Task with Full Context - -## TaskWing Workflow Contract v1 (Always On) -1. No implementation before a clarified and approved plan/task checkpoint. -2. No completion claim without fresh verification evidence. -3. No debug fix proposal without root-cause evidence. - -If any gate fails, stop and request the missing approval or evidence. - -Execute these steps IN ORDER. Do not skip any step. - -## Step 1: Get Next Task -Call MCP tool ` + "`task`" + ` with action ` + "`next`" + ` to retrieve the highest-priority pending task: -` + "```json" + ` -{"action": "next"} -` + "```" + ` - -` + "`session_id`" + ` is optional when called through MCP transport; include it only for explicit cross-session orchestration. - -Extract from the response: -- task_id, title, description -- scope (e.g., "auth", "vectorsearch", "api") -- keywords array -- acceptance_criteria -- suggested_ask_queries - -If no task returned, inform user: "No pending tasks. Use 'taskwing plan list' to check plan status." - -## Step 2: Fetch Scope-Relevant Context -Call MCP tool ` + "`ask`" + ` with query based on task scope: -` + "```json" + ` -{"query": "[task.scope] patterns constraints decisions"} -` + "```" + ` - -Examples: -- scope "auth" → ` + "`{\"query\": \"authentication cookies session patterns\"}`" + ` -- scope "api" → ` + "`{\"query\": \"api handlers middleware patterns\"}`" + ` -- scope "vectorsearch" → ` + "`{\"query\": \"lancedb embedding vector patterns\"}`" + ` - -Extract: patterns, constraints, related decisions. - -## Step 3: Fetch Task-Specific Context -Call MCP tool ` + "`ask`" + ` with keywords from the task. -Use ` + "`suggested_ask_queries`" + ` if available, otherwise extract keywords from title. -` + "```json" + ` -{"query": "[keywords from task title/description]"} -` + "```" + ` - -## Step 4: Claim the Task -Call MCP tool ` + "`task`" + ` with action ` + "`start`" + `: -` + "```json" + ` -{"action": "start", "task_id": "[task_id from step 1]"} -` + "```" + ` - -## Step 5: Present Unified Task Brief - -Display in this format: -` + "```" + ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -📋 TASK: [task_id] (Priority: [priority]) -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**[Title]** - -## Description -[Full task description] - -## Acceptance Criteria -- [ ] [Criterion 1] -- [ ] [Criterion 2] -- [ ] [Criterion 3] - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -🏗️ ARCHITECTURE CONTEXT -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -## Relevant Patterns -[Patterns from ask that apply to this task] - -## Constraints -[Constraints that must be respected] - -## Related Decisions -[Past decisions that inform this work] - -## Key Files -[Files likely to be modified based on context] - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -✅ Task claimed. Ready to begin. -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -` + "```" + ` - -## Step 6: Implementation Start Gate (Hard Gate) -Before writing or editing code, ask for an explicit checkpoint: -"Implementation checkpoint: proceed with task [task_id] now?" - -If approval is missing or unclear, STOP and respond with: -"REFUSAL: I can't start implementation yet. Plan/task checkpoint is incomplete. Please approve this task checkpoint first." - -## Step 7: Begin Implementation (Only After Approval) -Proceed with the task, following the patterns and respecting the constraints shown above. - -**CRITICAL**: You MUST call all MCP tools (` + "`task(next)`" + `, ` + "`ask`" + ` x2, ` + "`task(start)`" + `) before showing the brief and before requesting implementation approval. - -## Fallback (No MCP) -` + "```bash" + ` -taskwing task list # List all tasks -taskwing task list --status pending # Identify next pending task -taskwing plan status # Check active plan progress -` + "```" + ` -` - -// slashDoneContent is the prompt content for /taskwing:done -const slashDoneContent = `# Complete Task with Architecture-Aware Summary - -## TaskWing Workflow Contract v1 (Always On) -1. No implementation before a clarified and approved plan/task checkpoint. -2. No completion claim without fresh verification evidence. -3. No debug fix proposal without root-cause evidence. - -Execute these steps IN ORDER. - -## Step 1: Get Current Task -Call MCP tool ` + "`task`" + ` with action ` + "`current`" + `: -` + "```json" + ` -{"action": "current"} -` + "```" + ` - -If no active task, inform user and stop. - -## Step 2: Collect Fresh Verification Evidence -Run the most relevant verification commands for the task (tests, lint, build, or targeted checks). - -Document: -- command run -- exit status -- short output snippet proving pass/fail - -If verification was not run in this completion attempt, STOP and respond with: -"REFUSAL: I can't mark this task done yet. Verification evidence is missing. Run fresh checks and include the output." - -## Step 3: Generate Completion Report - -Create a structured summary covering: - -### Files Modified -List all files changed with purpose of change. - -### Acceptance Criteria Verification -For each criterion: -- ✅ **Met**: [How it was satisfied] -- ❌ **Not Met**: [Why, and what's needed] -- ⚠️ **Partial**: [What was done, what remains] - -### Pattern Compliance -Confirm alignment with codebase patterns. - -### Technical Debt / Follow-ups -- TODOs introduced -- Tests not written -- Edge cases not handled - -## Step 4: Completion Gate (Hard Gate) -Before calling ` + "`task complete`" + `, confirm: -- evidence is fresh (from Step 2) -- acceptance criteria status is explicit -- unresolved failures are called out - -If any item is missing, STOP and use the refusal text above. - -## Step 5: Mark Complete -Call MCP tool ` + "`task`" + ` with action ` + "`complete`" + `: -` + "```json" + ` -{ - "action": "complete", - "task_id": "[task_id]", - "summary": "[The structured summary from Step 2]", - "files_modified": ["path/to/file1.go", "path/to/file2.go"] -} -` + "```" + ` - -## Step 6: Confirm to User - -Display: -` + "```" + ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -✅ TASK COMPLETE: [task_id] -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -[Summary report] - -Recorded in TaskWing memory. -Use /taskwing:next to continue with next priority task. -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -` + "```" + ` - -## Fallback (No MCP) -` + "```bash" + ` -taskwing task complete TASK_ID -` + "```" + ` -` - -// slashStatusContent is the prompt content for /taskwing:status -const slashStatusContent = `# Show Current Task Status - -This is a read-only status command. Do not use it to bypass plan, verification, or debug gates. - -## Step 1: Get Current Task -Call MCP tool ` + "`task`" + ` with action ` + "`current`" + `: -` + "```json" + ` -{"action": "current"} -` + "```" + ` - -If no active task: -` + "```" + ` -No active task. Use /taskwing:next to start the next priority task. -` + "```" + ` - -## Step 2: Display Status - -` + "```" + ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -📊 CURRENT TASK STATUS -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Task: [task_id] - [title] -Priority: [priority] -Status: [status] -Started: [claimed_at timestamp] -Scope: [scope] - -## Acceptance Criteria -- [ ] [Criterion 1] -- [ ] [Criterion 2] - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Commands: - /taskwing:done - Complete this task - /taskwing:ask - Fetch more context -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -` + "```" + ` - -## Fallback (No MCP) -` + "```bash" + ` -taskwing task list --status in_progress -taskwing plan list -` + "```" + ` -` - -// slashPlanContent is the prompt content for /taskwing:plan -const slashPlanContent = `# Create Development Plan with Goal - -**Usage:** ` + "`/taskwing:plan `" + ` or ` + "`/taskwing:plan --batch `" + ` - -**Example:** ` + "`/taskwing:plan Add Stripe billing integration`" + ` - -## TaskWing Workflow Contract v1 (Always On) -1. No implementation before a clarified and approved plan/task checkpoint. -2. No completion claim without fresh verification evidence. -3. No debug fix proposal without root-cause evidence. - -Hard gate for this command: -- Do NOT generate, decompose, expand, or finalize a plan until the clarified goal checkpoint is explicitly approved. -- If approval is missing, STOP and respond with: - "REFUSAL: I can't move past planning yet. Clarification checkpoint is incomplete. Please approve the clarified goal first." - -## Mode Selection - -The plan tool supports two modes: -- **Interactive (default)**: Staged workflow with checkpoints at phases and tasks -- **Batch (--batch flag)**: Original all-at-once generation - -Check if $ARGUMENTS contains "--batch" flag: -- If yes: Use batch mode (Steps 1-4) -- If no: Use interactive mode (Steps 1-8) - ---- - -# BATCH MODE (when --batch is used) - -## Step 0: Check for Goal - -**If $ARGUMENTS is empty or not provided:** -Ask the user: "What do you want to build? Please describe your goal." -Wait for user response, then use that as the goal. - -**If $ARGUMENTS is provided:** -Use $ARGUMENTS as the goal and proceed to Step 1. - -## Step 1: Initial Clarification - -Call MCP tool ` + "`plan`" + ` with action ` + "`clarify`" + ` and the user's goal: -` + "```json" + ` -{"action": "clarify", "goal": "[goal from Step 0]"} -` + "```" + ` - -Extract: clarify_session_id, questions, goal_summary, enriched_goal, is_ready_to_plan, context_used. - -## Step 2: Ask Clarifying Questions (Loop) - -**If is_ready_to_plan is false:** -Present the questions to the user. Wait for user response. - -**If user says "auto":** -Call ` + "`plan`" + ` again with action ` + "`clarify`" + `, clarify_session_id, and auto_answer: true. - -**If user provides answers:** -Format answers as JSON and call ` + "`plan`" + ` again with action ` + "`clarify`" + ` and clarify_session_id: -` + "```json" + ` -{ - "action": "clarify", - "clarify_session_id": "[clarify_session_id from previous clarify step]", - "answers": [{"question":"...","answer":"..."}] -} -` + "```" + ` - -Repeat until is_ready_to_plan is true. - -## Step 3: Clarification Checkpoint Approval (Hard Gate) -Before generating: -- present enriched_goal and assumptions -- ask for explicit approval ("approve", "yes", or equivalent) - -If approval is not explicit, STOP and use the refusal text above. - -## Step 4: Generate Plan - -When is_ready_to_plan is true, call MCP tool ` + "`plan`" + ` with action ` + "`generate`" + `: -` + "```json" + ` -{ - "action": "generate", - "goal": "$ARGUMENTS", - "clarify_session_id": "[clarify_session_id from clarify loop]", - "enriched_goal": "[enriched_goal from step 2]", - "save": true -} -` + "```" + ` - -## Step 5: Present Plan Summary - -Display the generated plan: -` + "```" + ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -✅ PLAN CREATED: [plan_id] -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**Goal:** [goal] - -## Generated Tasks - -| # | Title | Priority | -|---|-------|----------| -| 1 | [Task 1 title] | [priority] | -| 2 | [Task 2 title] | [priority] | -... - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -📋 Plan saved and set as active. - -**Next steps:** -- Run /taskwing:next to start working on the first task -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -` + "```" + ` - ---- - -# INTERACTIVE MODE (default when no --batch flag) - -## Step 1: Check for Goal (Same as Batch) - -**If $ARGUMENTS is empty or not provided:** -Ask the user: "What do you want to build? Please describe your goal." -Wait for user response, then use that as the goal. - -## Step 2: Clarify Goal - -Call MCP tool ` + "`plan`" + ` with action=clarify: -` + "```json" + ` -{"action": "clarify", "goal": "[goal from Step 1]", "mode": "interactive"} -` + "```" + ` - -Ask clarifying questions until is_ready_to_plan is true. -Save the clarify_session_id and enriched_goal for subsequent steps. - -**CHECKPOINT 1**: User approves the enriched goal before proceeding. -If approval is not explicit, STOP and use the refusal text above. - -## Step 3: Decompose into Phases - -Call MCP tool ` + "`plan`" + ` with action=decompose: -` + "```json" + ` -{ - "action": "decompose", - "plan_id": "[plan_id from Step 2]", - "enriched_goal": "[enriched_goal from Step 2]" -} -` + "```" + ` - -This returns 3-5 high-level phases. Present them to the user: - -` + "```" + ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -📋 PROPOSED PHASES -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -## Phase 1: [Title] -[Description] -Rationale: [Why this phase is needed] -Expected tasks: [N] - -## Phase 2: [Title] -... - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -` + "```" + ` - -**CHECKPOINT 2**: Ask user to: -- Approve phases as-is -- Request regeneration with feedback -- Skip specific phases - -## Step 4: Expand Each Phase (Loop) - -For each approved phase, call MCP tool ` + "`plan`" + ` with action=expand: -` + "```json" + ` -{ - "action": "expand", - "plan_id": "[plan_id]", - "phase_id": "[phase_id]" -} -` + "```" + ` - -This returns 2-4 detailed tasks for the phase. Present them: - -` + "```" + ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -📋 TASKS FOR PHASE: [Phase Title] -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -## Task 1: [Title] -Priority: [priority] -Description: [description] -Acceptance Criteria: -- [criterion 1] -- [criterion 2] - -## Task 2: [Title] -... - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Remaining phases: [N] -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -` + "```" + ` - -**CHECKPOINT 3** (per phase): Ask user to: -- Approve tasks and continue to next phase -- Request regeneration with feedback -- Skip this phase - -Repeat for each phase until all are expanded. - -## Step 5: Finalize Plan - -After all phases are expanded, call MCP tool ` + "`plan`" + ` with action=finalize: -` + "```json" + ` -{ - "action": "finalize", - "plan_id": "[plan_id]" -} -` + "```" + ` - -## Step 6: Present Final Summary - -` + "```" + ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -✅ PLAN FINALIZED: [plan_id] -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**Goal:** [goal] - -## Phases & Tasks - -### Phase 1: [Title] - 1. [Task 1 title] (Priority: [P]) - 2. [Task 2 title] (Priority: [P]) - -### Phase 2: [Title] - 3. [Task 3 title] (Priority: [P]) - 4. [Task 4 title] (Priority: [P]) - -... - -**Total:** [N] phases, [M] tasks -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -📋 Plan saved and set as active. - -**Next steps:** -- Run /taskwing:next to start working on the first task -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -` + "```" + ` - ---- - -## Fallback (No MCP) -` + "```bash" + ` -taskwing goal "Your goal description" # Preferred -taskwing plan new "Your goal description" # Advanced mode -taskwing plan new --non-interactive "Your goal description" # Headless mode -` + "```" + ` -` - -// slashSimplifyContent is the prompt content for /taskwing:simplify -const slashSimplifyContent = `# Simplify Code - -**Usage:** ` + "`/taskwing:simplify [file_path or paste code]`" + ` - -Reduce code complexity while preserving behavior. -This command is optimization-only and must not bypass planning, verification, or debugging gates. - -## Step 1: Get the Code - -**If $ARGUMENTS is a file path:** -Call MCP tool ` + "`code`" + ` with action=simplify: -` + "```json" + ` -{"action": "simplify", "file_path": "[file path from arguments]"} -` + "```" + ` - -**If $ARGUMENTS is code or empty:** -Ask the user to paste the code, then call: -` + "```json" + ` -{"action": "simplify", "code": "[pasted code]"} -` + "```" + ` - -## Step 2: Review Results - -The tool returns: -- Simplified code -- Line count reduction (before/after) -- List of changes made with reasoning -- Risk assessment - -## Step 3: Present to User - -` + "```" + ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -🧹 CODE SIMPLIFICATION -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -## Simplified Code -[The simplified version] - -## Summary -Lines: [before] → [after] (-[reduction]%) -Risk: [risk level] - -## Changes Made -- [What was changed and why] -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -` + "```" + ` - -## Step 4: Offer to Apply - -Ask if the user wants to apply the changes to the file. - -## Fallback (No MCP) -` + "```bash" + ` -# Manual review recommended -` + "```" + ` -` - -// slashDebugContent is the prompt content for /taskwing:debug -const slashDebugContent = `# Debug Issue - -**Usage:** ` + "`/taskwing:debug `" + ` - -**Example:** ` + "`/taskwing:debug API returns 500 on /users endpoint`" + ` - -## TaskWing Workflow Contract v1 (Always On) -1. No implementation before a clarified and approved plan/task checkpoint. -2. No completion claim without fresh verification evidence. -3. No debug fix proposal without root-cause evidence. - -## Phase 1: Capture Problem Statement - -**If $ARGUMENTS is empty:** -Ask the user: "What issue are you experiencing? Please describe the problem, and optionally include any error messages or stack traces." -Wait for user response. - -**If $ARGUMENTS is provided:** -Use $ARGUMENTS as the problem description. - -## Phase 2: Root-Cause Evidence Collection (Hard Gate) -Call MCP tool ` + "`debug`" + ` with the best available evidence: - -` + "```json" + ` -{ - "problem": "[problem description]", - "error": "[error message if available]", - "stack_trace": "[stack trace if available]" -} -` + "```" + ` - -Do NOT propose fixes yet. First collect and present: -- likely failing component -- top hypotheses -- concrete investigation commands - -If the response lacks root-cause evidence (only symptoms), STOP and respond with: -"REFUSAL: I can't propose a fix yet. Root-cause evidence is missing. Run the investigation steps first and share results." - -## Phase 3: Present Investigation Plan -Display the debug analysis: - -` + "```" + ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -🔍 DEBUG ANALYSIS -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -## Most Likely Cause -[Primary hypothesis] - -## Hypotheses (Ranked) -🔴 1. [High likelihood cause] - [Reasoning] - 📍 Check: [file locations] - -🟡 2. [Medium likelihood cause] - [Reasoning] - -🔵 3. [Lower likelihood cause] - [Reasoning] - -## Investigation Steps -1. [First step to try] - ` + "```" + ` - [command to run] - ` + "```" + ` - -2. [Second step] - ... - -## Quick Fixes -- [Quick fix if applicable] -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -` + "```" + ` - -## Phase 4: Fix Proposal (Only After Evidence Gate Passes) -After Phase 2 evidence is present, propose the smallest safe fix and ask whether to implement it now. - -## Step 5: Offer to Help -Ask if the user wants help running investigation steps or implementing the proposed fix. - -## Fallback (No MCP) -` + "```bash" + ` -taskwing plan status -` + "```" + ` -` - -// slashExplainContent is the prompt content for /taskwing:explain -const slashExplainContent = `# Explain Code Symbol - -**Usage:** ` + "`/taskwing:explain `" + ` - -**Example:** ` + "`/taskwing:explain NewAskApp`" + ` - -Get a deep-dive explanation of a code symbol including its purpose, usage patterns, and call graph. -This is an analysis command and must not be used to bypass planning, verification, or debug gates. - -## Step 1: Get the Symbol - -**If $ARGUMENTS is empty:** -Ask the user: "What symbol would you like me to explain? (function, type, method, or variable name)" -Wait for user response. - -**If $ARGUMENTS is provided:** -Use $ARGUMENTS as the symbol query. - -## Step 2: Call Explain Tool - -Call MCP tool ` + "`code`" + ` with action=explain: -` + "```json" + ` -{"action": "explain", "query": "[symbol name from arguments]"} -` + "```" + ` - -Optional: Add depth parameter (1-5) for call graph depth: -` + "```json" + ` -{"action": "explain", "query": "[symbol]", "depth": 3} -` + "```" + ` - -## Step 3: Present Explanation - -Display the analysis: -` + "```" + ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -📖 SYMBOL EXPLANATION -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -## [Symbol Name] -📍 [file_path:line_number] - -## Summary -[One-line description of what this symbol does] - -## Detailed Explanation -[Multi-paragraph explanation of purpose, behavior, and implementation details] - -## Connections -[Related symbols, dependencies, and how this fits in the codebase] - -## Common Pitfalls -[Mistakes to avoid when using or modifying this symbol] - -## Usage Examples -[Code examples showing how to use this symbol] - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -` + "```" + ` - -## Step 4: Offer Follow-ups - -Suggest related actions: -- Explain a related symbol -- View call graph with MCP tool ` + "`code`" + ` action ` + "`callers`" + ` -- See impact analysis - -## Fallback (No MCP) -` + "```bash" + ` -taskwing mcp -` + "```" + ` -` - -// slashAskContent is the prompt content for /taskwing:ask -const slashAskContent = `# Project Knowledge Brief - -This is a context-priming command and must not be used to bypass planning, verification, or debug gates. - -Call MCP tool ` + "`ask`" + ` to get a compact project knowledge brief. - -Use: -` + "```json" + ` -{"query":"project decisions patterns constraints", "answer": true} -` + "```" + ` - -If you need broader coverage, run: -` + "```json" + ` -{"all": true} -` + "```" + ` - -Present the returned summary and top results to prime the conversation with project knowledge. -` - -// slashRememberContent is the prompt content for /taskwing:remember -const slashRememberContent = `# Store Knowledge in Project Memory - -This is a persistence command and must not be used to bypass planning, verification, or debug gates. - -Call MCP tool ` + "`remember`" + ` to persist a decision, pattern, or insight to project memory. - -Use: -` + "```json" + ` -{"content": "[the knowledge to store]"} -` + "```" + ` - -Optionally specify a type (decision, pattern, constraint, note): -` + "```json" + ` -{"content": "[the knowledge to store]", "type": "decision"} -` + "```" + ` - -The content will be classified automatically using AI if no type is provided. -` diff --git a/cmd/task.go b/cmd/task.go index 6eb2570..52a195c 100644 --- a/cmd/task.go +++ b/cmd/task.go @@ -69,7 +69,7 @@ func runTaskList(cmd *cobra.Command, args []string) error { if isJSON() { return printJSON([]any{}) } - fmt.Println("No plans found. Create one with: taskwing goal \"Your goal\"") + fmt.Println("No plans found. Use /taskwing:plan in your AI tool to create one") return nil } @@ -122,36 +122,36 @@ func runTaskList(cmd *cobra.Command, args []string) error { // Handle JSON output if isJSON() { type taskJSON struct { - ID string `json:"id"` - PlanID string `json:"plan_id"` - PlanStatus string `json:"plan_status"` - Title string `json:"title"` - Description string `json:"description"` - Status string `json:"status"` - Priority int `json:"priority"` - Agent string `json:"assigned_agent"` - Acceptance []string `json:"acceptance_criteria"` - Validation []string `json:"validation_steps"` - Scope string `json:"scope"` - Keywords []string `json:"keywords"` + ID string `json:"id"` + PlanID string `json:"plan_id"` + PlanStatus string `json:"plan_status"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Priority int `json:"priority"` + Agent string `json:"assigned_agent"` + Acceptance []string `json:"acceptance_criteria"` + Validation []string `json:"validation_steps"` + Scope string `json:"scope"` + Keywords []string `json:"keywords"` SuggestedAskQueries []string `json:"suggestedAskQueries"` } var jsonTasks []taskJSON for _, tp := range allTasks { t := tp.Task jsonTasks = append(jsonTasks, taskJSON{ - ID: t.ID, - PlanID: tp.PlanID, - PlanStatus: string(tp.PlanStatus), - Title: t.Title, - Description: t.Description, - Status: string(t.Status), - Priority: t.Priority, - Agent: t.AssignedAgent, - Acceptance: t.AcceptanceCriteria, - Validation: t.ValidationSteps, - Scope: t.Scope, - Keywords: t.Keywords, + ID: t.ID, + PlanID: tp.PlanID, + PlanStatus: string(tp.PlanStatus), + Title: t.Title, + Description: t.Description, + Status: string(t.Status), + Priority: t.Priority, + Agent: t.AssignedAgent, + Acceptance: t.AcceptanceCriteria, + Validation: t.ValidationSteps, + Scope: t.Scope, + Keywords: t.Keywords, SuggestedAskQueries: t.SuggestedAskQueries, }) } @@ -830,7 +830,7 @@ func runTaskAdd(cmd *cobra.Command, args []string) error { // Use active plan plan, err := repo.GetActivePlan() if err != nil || plan == nil { - return fmt.Errorf("no active plan. Use --plan to specify a plan, or run 'taskwing plan start ' to set an active plan") + return fmt.Errorf("no active plan. Use /taskwing:plan in your AI tool to create one") } planID = plan.ID } diff --git a/docs/PRODUCT_VISION.md b/docs/PRODUCT_VISION.md index f5364ee..f8353b9 100644 --- a/docs/PRODUCT_VISION.md +++ b/docs/PRODUCT_VISION.md @@ -55,7 +55,7 @@ Brand names and logos are trademarks of their respective owners; usage here indi ```text ┌─────────────────────────────────────────────────────────┐ │ USER INTERFACE │ -│ taskwing goal "..." │ /taskwing:next │ /taskwing:done │ +│ /taskwing:plan │ /taskwing:next │ /taskwing:done │ └─────────────────────────────────────────────────────────┘ │ v @@ -83,11 +83,8 @@ Brand names and logos are trademarks of their respective owners; usage here indi - `taskwing bootstrap` -- `taskwing goal ""` - `taskwing ask ""` - `taskwing task` -- `taskwing plan status` -- `taskwing slash` - `taskwing mcp` - `taskwing doctor` - `taskwing config` @@ -110,7 +107,7 @@ Brand names and logos are trademarks of their respective owners; usage here indi ## Success Metrics 1. Task accuracy: generated tasks reference correct files and patterns. -2. Developer adoption: daily active users running `taskwing goal`. +2. Developer adoption: daily active users running `/taskwing:plan`. 3. Context utilization: MCP queries per plan execution. 4. Time-to-root-cause: bug investigations with TaskWing context vs. without. diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index ef93fef..02ebb8e 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -38,11 +38,13 @@ This creates `.taskwing/` and installs AI assistant integration files. ## 2. Create and Activate a Plan -```bash -taskwing goal "Add user authentication" +In your AI tool, use the MCP workflow: + +```text +/taskwing:plan ``` -`taskwing goal` runs clarify -> generate -> activate in one step. +This runs clarify -> generate -> activate in one step via MCP. ## 3. Execute with Slash Commands @@ -61,7 +63,7 @@ When done: Check current status: ```text -/taskwing:status +/taskwing:context ``` ## 3.5. First-Run Success Loop (<15 minutes) @@ -78,7 +80,6 @@ If you complete this loop once, your setup is healthy and your assistant workflo ## 4. Inspect Progress from CLI ```bash -taskwing plan status taskwing task list ``` @@ -154,11 +155,8 @@ Recommended Bedrock model IDs: - `taskwing bootstrap` -- `taskwing goal ""` - `taskwing ask ""` - `taskwing task` -- `taskwing plan status` -- `taskwing slash` - `taskwing mcp` - `taskwing doctor` - `taskwing config` diff --git a/docs/WORKFLOW_CONTRACT_V1.md b/docs/WORKFLOW_CONTRACT_V1.md index d4c4f48..76f2f19 100644 --- a/docs/WORKFLOW_CONTRACT_V1.md +++ b/docs/WORKFLOW_CONTRACT_V1.md @@ -47,5 +47,5 @@ KPI: ## Operating Policy - These gates are hard blockers for core workflow commands. -- Commands that are primarily read-only (`/taskwing:ask`, `/taskwing:status`, `/taskwing:explain`, `/taskwing:simplify`) remain lightweight but must not bypass these gates. +- Commands that are primarily read-only (`/taskwing:context`) and MCP tools (`ask`, `code`, `remember`) remain lightweight but must not bypass these gates. - Prompt regressions against this contract are release blockers. diff --git a/docs/WORKFLOW_PACK.md b/docs/WORKFLOW_PACK.md index 3b72e60..50f1c52 100644 --- a/docs/WORKFLOW_PACK.md +++ b/docs/WORKFLOW_PACK.md @@ -18,7 +18,7 @@ Get users to one visible success loop in under 15 minutes: ## First-Run Activation Path 1. `taskwing bootstrap` -2. `taskwing goal ""` +2. `/taskwing:plan` 3. `/taskwing:next` 4. Implement scoped change 5. `/taskwing:done` diff --git a/docs/_partials/core_commands.md b/docs/_partials/core_commands.md deleted file mode 100644 index ecaf254..0000000 --- a/docs/_partials/core_commands.md +++ /dev/null @@ -1,10 +0,0 @@ -- `taskwing bootstrap` -- `taskwing goal ""` -- `taskwing ask ""` -- `taskwing task` -- `taskwing plan status` -- `taskwing slash` -- `taskwing mcp` -- `taskwing doctor` -- `taskwing config` -- `taskwing start` diff --git a/docs/_partials/legal.md b/docs/_partials/legal.md deleted file mode 100644 index 74311b7..0000000 --- a/docs/_partials/legal.md +++ /dev/null @@ -1 +0,0 @@ -Brand names and logos are trademarks of their respective owners; usage here indicates compatibility, not endorsement. diff --git a/docs/_partials/mcp_tools.md b/docs/_partials/mcp_tools.md deleted file mode 100644 index 3d6b05d..0000000 --- a/docs/_partials/mcp_tools.md +++ /dev/null @@ -1,8 +0,0 @@ -| Tool | Description | -|------|-------------| -| `ask` | Search project knowledge (decisions, patterns, constraints) | -| `task` | Unified task lifecycle (`next`, `current`, `start`, `complete`) | -| `plan` | Plan management (`clarify`, `decompose`, `expand`, `generate`, `finalize`, `audit`) | -| `code` | Code intelligence (`find`, `search`, `explain`, `callers`, `impact`, `simplify`) | -| `debug` | Diagnose issues systematically with AI-powered analysis | -| `remember` | Store knowledge in project memory | diff --git a/docs/_partials/providers.md b/docs/_partials/providers.md deleted file mode 100644 index 0a0f0da..0000000 --- a/docs/_partials/providers.md +++ /dev/null @@ -1,5 +0,0 @@ -[![OpenAI](https://img.shields.io/badge/OpenAI-412991?logo=openai&logoColor=white)](https://platform.openai.com/) -[![Anthropic](https://img.shields.io/badge/Anthropic-191919?logo=anthropic&logoColor=white)](https://www.anthropic.com/) -[![Google Gemini](https://img.shields.io/badge/Google_Gemini-4285F4?logo=google&logoColor=white)](https://ai.google.dev/) -[![AWS Bedrock](https://img.shields.io/badge/AWS_Bedrock-OpenAI--Compatible_Beta-FF9900?logo=amazonaws&logoColor=white)](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-chat-completions.html) -[![Ollama](https://img.shields.io/badge/Ollama-Local-000000?logo=ollama&logoColor=white)](https://ollama.com/) diff --git a/docs/_partials/tools.md b/docs/_partials/tools.md deleted file mode 100644 index 4010140..0000000 --- a/docs/_partials/tools.md +++ /dev/null @@ -1,6 +0,0 @@ -[![Claude Code](https://img.shields.io/badge/Claude_Code-191919?logo=anthropic&logoColor=white)](https://www.anthropic.com/claude-code) -[![OpenAI Codex](https://img.shields.io/badge/OpenAI_Codex-412991?logo=openai&logoColor=white)](https://developers.openai.com/codex) -[![Cursor](https://img.shields.io/badge/Cursor-111111?logo=cursor&logoColor=white)](https://cursor.com/) -[![GitHub Copilot](https://img.shields.io/badge/GitHub_Copilot-181717?logo=githubcopilot&logoColor=white)](https://github.com/features/copilot) -[![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-4285F4?logo=google&logoColor=white)](https://github.com/google-gemini/gemini-cli) -[![OpenCode](https://img.shields.io/badge/OpenCode-000000?logo=opencode&logoColor=white)](https://opencode.ai/) diff --git a/docs/positioning/freshness-validation-plan.md b/docs/positioning/freshness-validation-plan.md index 7b54a88..efc86eb 100644 --- a/docs/positioning/freshness-validation-plan.md +++ b/docs/positioning/freshness-validation-plan.md @@ -1,7 +1,7 @@ # Query-Time Freshness Validation - Implementation Plan **Date:** 2026-03-15 -**Status:** Planned +**Status:** Level 1 implemented (with deviations noted below) ## Problem diff --git a/go.mod b/go.mod index 1faf047..d8018ef 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,8 @@ require ( golang.org/x/text v0.32.0 golang.org/x/tools v0.39.0 google.golang.org/genai v1.36.0 + google.golang.org/grpc v1.77.0 + google.golang.org/protobuf v1.36.10 modernc.org/sqlite v1.40.1 ) @@ -164,8 +166,6 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect - google.golang.org/grpc v1.77.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 8fe0d00..668121a 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs= cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= @@ -47,8 +45,6 @@ github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/internal/agents/core/parsers_test.go b/internal/agents/core/parsers_test.go index 26b62cc..f8b2f25 100644 --- a/internal/agents/core/parsers_test.go +++ b/internal/agents/core/parsers_test.go @@ -32,15 +32,15 @@ func TestParseJSONResponse_Hallucination(t *testing.T) { wantErr: true, }, { - name: "valid JSON accepted", - input: `{"title": "Valid Finding", "description": "desc", "evidence": [{"file_path": "main.go"}]}`, - wantErr: false, + name: "valid JSON accepted", + input: `{"title": "Valid Finding", "description": "desc", "evidence": [{"file_path": "main.go"}]}`, + wantErr: false, wantTitle: "Valid Finding", }, { - name: "JSON with markdown fences accepted", - input: "```json\n{\"title\": \"Fenced\", \"description\": \"d\"}\n```", - wantErr: false, + name: "JSON with markdown fences accepted", + input: "```json\n{\"title\": \"Fenced\", \"description\": \"d\"}\n```", + wantErr: false, wantTitle: "Fenced", }, { @@ -50,9 +50,9 @@ func TestParseJSONResponse_Hallucination(t *testing.T) { wantTitle: "Truncated", }, { - name: "JSON with trailing comma repaired", - input: `{"title": "Trailing", "description": "desc",}`, - wantErr: false, + name: "JSON with trailing comma repaired", + input: `{"title": "Trailing", "description": "desc",}`, + wantErr: false, wantTitle: "Trailing", }, } diff --git a/internal/agents/impl/analysis_code_deterministic.go b/internal/agents/impl/analysis_code_deterministic.go index e73a366..3fc84d9 100644 --- a/internal/agents/impl/analysis_code_deterministic.go +++ b/internal/agents/impl/analysis_code_deterministic.go @@ -288,22 +288,33 @@ func (a *CodeAgent) runChunkedAnalysis(ctx context.Context, input core.Input, ba } // formatExistingKnowledge formats existing knowledge nodes for the prompt. +// Also includes wave1 context from two-wave bootstrap execution if available. func (a *CodeAgent) formatExistingKnowledge(existingContext map[string]any) string { if existingContext == nil { return "" } + var sb strings.Builder + + // Include wave1 summary from two-wave execution + if wave1Summary, ok := existingContext["wave1_summary"]; ok { + if summary, ok := wave1Summary.(string); ok && summary != "" { + sb.WriteString("## Context from Documentation & Dependencies Analysis\n") + sb.WriteString(summary) + sb.WriteString("\n\n") + } + } + nodesObj, ok := existingContext["existing_nodes"] if !ok { - return "" + return sb.String() } nodes, ok := nodesObj.([]memory.Node) if !ok || len(nodes) == 0 { - return "" + return sb.String() } - var sb strings.Builder for _, n := range nodes { fmt.Fprintf(&sb, "- [%s] %s: %s\n", n.Type, n.ID, n.Summary) } diff --git a/internal/agents/impl/analysis_deps.go b/internal/agents/impl/analysis_deps.go index 3af13ba..3a9dfa8 100644 --- a/internal/agents/impl/analysis_deps.go +++ b/internal/agents/impl/analysis_deps.go @@ -5,10 +5,13 @@ package impl import ( "context" + "errors" "fmt" "io" + "log" "os" "os/exec" + "path/filepath" "strings" "github.com/josephgoksu/TaskWing/internal/agents/core" @@ -43,7 +46,36 @@ func (a *DepsAgent) Close() error { // Run executes the agent using Eino DeterministicChain. func (a *DepsAgent) Run(ctx context.Context, input core.Input) (core.Output, error) { - // Initialize chain (lazy) + // Quick pre-check: skip ReAct entirely if no dependency files exist. + // This avoids wasting 20 tool calls exploring an empty project. + if !hasAnyDependencyFile(input.BasePath) { + return core.Output{AgentName: a.Name(), Error: fmt.Errorf("no dependency files found")}, nil + } + + // ReAct mode: attempt tool-calling exploration for richer findings. + // Tried BEFORE chain init to avoid wasting an LLM connection if ReAct succeeds. + { + userMsg := fmt.Sprintf("Analyze the dependencies for project %q. Start by listing the root directory to find dependency manifests (package.json, go.mod, Cargo.toml, etc.).", input.ProjectName) + raw, duration, err := runReactMode(ctx, a.LLMConfig(), input.BasePath, config.SystemPromptDepsReactAgent, userMsg, 20) + if err == nil && raw != "" { + parsed, parseErr := core.ParseJSONResponse[depsTechDecisionsResponse](raw) + if parseErr == nil { + findings := a.parseFindings(parsed) + if len(findings) >= reactMinFindingsDeps { + output := core.BuildOutput(a.Name(), findings, "ReAct exploration", duration) + return output, nil + } + if len(findings) > 0 { + log.Printf("[deps] ReAct produced only %d findings (threshold %d), falling back to deterministic", len(findings), reactMinFindingsDeps) + } + } + } + if err != nil && !errors.Is(err, ErrNoToolCalling) { + log.Printf("[deps] ReAct mode failed, falling back to deterministic: %v", err) + } + } + + // Initialize chain for deterministic fallback (lazy) if a.chain == nil { chatModel, err := a.CreateCloseableChatModel(ctx) if err != nil { @@ -62,7 +94,7 @@ func (a *DepsAgent) Run(ctx context.Context, input core.Input) (core.Output, err a.chain = chain } - // Initialize budget - use safe budget to avoid exceeding practical API limits + // Deterministic fallback: gather deps upfront, single LLM call limit := llm.GetMaxInputTokens(a.LLMConfig().Model) budget := tools.NewSafeContextBudget(int(float64(limit) * 0.7)) @@ -237,6 +269,23 @@ func readFileWithLimit(path string, maxBytes int) ([]byte, error) { return content[:n], nil } +// commonDepFiles lists dependency manifests to check before running analysis. +var commonDepFiles = []string{ + "package.json", "go.mod", "Cargo.toml", "requirements.txt", + "Pipfile", "pyproject.toml", "pom.xml", "build.gradle", + "build.gradle.kts", "Gemfile", "composer.json", "pubspec.yaml", +} + +// hasAnyDependencyFile checks if at least one common dependency manifest exists. +func hasAnyDependencyFile(basePath string) bool { + for _, name := range commonDepFiles { + if _, err := os.Stat(filepath.Join(basePath, name)); err == nil { + return true + } + } + return false +} + func init() { core.RegisterAgent("deps", func(cfg llm.Config, basePath string) core.Agent { return NewDepsAgent(cfg) diff --git a/internal/agents/impl/analysis_doc.go b/internal/agents/impl/analysis_doc.go index 4a5f946..3947599 100644 --- a/internal/agents/impl/analysis_doc.go +++ b/internal/agents/impl/analysis_doc.go @@ -5,8 +5,10 @@ package impl import ( "context" + "errors" "fmt" "io" + "log/slog" "strings" "time" @@ -41,7 +43,30 @@ func (a *DocAgent) Close() error { // Run executes the agent using Eino DeterministicChain. func (a *DocAgent) Run(ctx context.Context, input core.Input) (core.Output, error) { - // Initialize chain if not ready (lazy init to support config updates if needed) + // ReAct mode: attempt tool-calling exploration for richer findings. + // Tried BEFORE chain init to avoid wasting an LLM connection if ReAct succeeds. + if input.Mode != core.ModeWatch { + userMsg := fmt.Sprintf("Analyze the documentation for project %q. Start by listing the root directory to find documentation files.", input.ProjectName) + raw, duration, err := runReactMode(ctx, a.LLMConfig(), input.BasePath, config.SystemPromptDocReactAgent, userMsg, 15) + if err == nil && raw != "" { + parsed, parseErr := core.ParseJSONResponse[docAnalysisResponse](raw) + if parseErr == nil { + findings, relationships := a.parseFindings(parsed) + if len(findings) >= reactMinFindingsDoc { + output := core.BuildOutputWithRelationships(a.Name(), findings, relationships, "ReAct exploration", duration) + return output, nil + } + if len(findings) > 0 { + slog.Debug("[doc] ReAct produced only N findings, falling back to deterministic", "count", len(findings), "threshold", reactMinFindingsDoc) + } + } + } + if err != nil && !errors.Is(err, ErrNoToolCalling) { + slog.Debug("[doc] ReAct mode failed, falling back to deterministic", "error", err) + } + } + + // Initialize chain if not ready (lazy init for deterministic fallback) if a.chain == nil { chatModel, err := a.CreateCloseableChatModel(ctx) if err != nil { @@ -67,8 +92,7 @@ func (a *DocAgent) Run(ctx context.Context, input core.Input) (core.Output, erro gatherer := tools.NewContextGatherer(input.BasePath) gatherer.SetBudget(budget) - // Split work into parallel tracks if not in watch mode - // Watch mode usually only has small changes, so we keep it simple + // Watch mode: simple single-pass for changed markdown files if input.Mode == core.ModeWatch && len(input.ChangedFiles) > 0 { docContent := gatherer.GatherSpecificFiles(filterMarkdown(input.ChangedFiles)) if docContent == "" { @@ -191,6 +215,14 @@ type docAnalysisResponse struct { Evidence []core.EvidenceJSON `json:"evidence"` SourceFile string `json:"source_file"` } `json:"features"` + Decisions []struct { + Title string `json:"title"` + Summary string `json:"summary"` + Alternatives string `json:"alternatives"` + Confidence any `json:"confidence"` + Evidence []core.EvidenceJSON `json:"evidence"` + SourceFile string `json:"source_file"` + } `json:"decisions"` Constraints []struct { Rule string `json:"rule"` Reason string `json:"reason"` @@ -236,6 +268,28 @@ func (a *DocAgent) parseFindings(parsed docAnalysisResponse) ([]core.Finding, [] }) } + for _, d := range parsed.Decisions { + evidence := core.ConvertEvidence(d.Evidence) + if len(evidence) == 0 && d.SourceFile != "" { + evidence = []core.Evidence{{FilePath: d.SourceFile}} + } + confidenceScore, confidenceLabel := core.ParseConfidence(d.Confidence) + description := d.Summary + if d.Alternatives != "" { + description += "\nAlternatives considered: " + d.Alternatives + } + findings = append(findings, core.Finding{ + Type: core.FindingTypeDecision, + Title: d.Title, + Description: description, + ConfidenceScore: confidenceScore, + Confidence: confidenceLabel, + Evidence: evidence, + VerificationStatus: core.VerificationStatusPending, + SourceAgent: a.Name(), + }) + } + for _, w := range parsed.Workflows { evidence := core.ConvertEvidence(w.Evidence) if len(evidence) == 0 && w.SourceFile != "" { diff --git a/internal/agents/impl/analysis_git.go b/internal/agents/impl/analysis_git.go index fd2cc60..89eb72d 100644 --- a/internal/agents/impl/analysis_git.go +++ b/internal/agents/impl/analysis_git.go @@ -5,6 +5,7 @@ package impl import ( "context" + "errors" "fmt" "io" "log" @@ -57,7 +58,44 @@ func (a *GitAgent) Close() error { func (a *GitAgent) Run(ctx context.Context, input core.Input) (core.Output, error) { start := time.Now() - // Initialize chain (lazy) + // Gather commits early to check if git history exists (needed for both paths) + chunks, projectMeta := gatherGitChunks(input.BasePath, input.Verbose) + if len(chunks) == 0 { + return core.Output{AgentName: a.Name(), Error: fmt.Errorf("no git history available")}, nil + } + + // ReAct mode: attempt tool-calling exploration for richer findings. + // Tried BEFORE chain init to avoid wasting an LLM connection if ReAct succeeds. + { + userMsg := fmt.Sprintf("Analyze the git history for project %q. Start by running git log --oneline -100 to get an overview of recent commits.", input.ProjectName) + raw, reactDuration, err := runReactMode(ctx, a.LLMConfig(), input.BasePath, config.SystemPromptGitReactAgent, userMsg, 15) + if err == nil && raw != "" { + parsed, parseErr := core.ParseJSONResponse[gitMilestonesResponse](raw) + if parseErr == nil { + findings := a.parseFindings(parsed) + if len(findings) >= reactMinFindingsGit { + output := core.BuildOutput(a.Name(), findings, "ReAct exploration", reactDuration) + output.Coverage = core.CoverageStats{ + FilesAnalyzed: 1, + TotalFiles: 1, + CoveragePercent: 100, + FilesRead: []core.FileRead{{ + Path: ".git/logs/HEAD (ReAct exploration)", + }}, + } + return output, nil + } + if len(findings) > 0 { + log.Printf("[git] ReAct produced only %d findings (threshold %d), falling back to deterministic", len(findings), reactMinFindingsGit) + } + } + } + if err != nil && !errors.Is(err, ErrNoToolCalling) { + log.Printf("[git] ReAct mode failed, falling back to deterministic: %v", err) + } + } + + // Initialize chain for deterministic fallback (lazy) if a.chain == nil { chatModel, err := a.CreateCloseableChatModel(ctx) if err != nil { @@ -76,12 +114,6 @@ func (a *GitAgent) Run(ctx context.Context, input core.Input) (core.Output, erro a.chain = chain } - // Gather commits and split into chunks - chunks, projectMeta := gatherGitChunks(input.BasePath, input.Verbose) - if len(chunks) == 0 { - return core.Output{AgentName: a.Name(), Error: fmt.Errorf("no git history available")}, nil - } - // Process chunks with recency weighting (newest first) var allFindings []core.Finding seenTitles := make(map[string]bool) diff --git a/internal/agents/impl/planning_context.go b/internal/agents/impl/planning_context.go deleted file mode 100644 index fd26149..0000000 --- a/internal/agents/impl/planning_context.go +++ /dev/null @@ -1,311 +0,0 @@ -package impl - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - - "github.com/josephgoksu/TaskWing/internal/knowledge" - "github.com/josephgoksu/TaskWing/internal/llm" - "github.com/josephgoksu/TaskWing/internal/memory" - "github.com/josephgoksu/TaskWing/internal/policy" - "github.com/spf13/afero" -) - -// SearchStrategyResult contains the context and the strategy description -type SearchStrategyResult struct { - Context string - Strategy string -} - -// PolicyConstraintsBudget is the maximum tokens to allocate for policy constraints. -// This ensures policy injection doesn't overflow the context budget. -const PolicyConstraintsBudget = 2000 - -// loadArchitectureMD attempts to load the generated ARCHITECTURE.md file. -// Returns empty string if not found (graceful degradation). -func loadArchitectureMD(basePath string) string { - if basePath == "" { - return "" - } - archPath := filepath.Join(basePath, "ARCHITECTURE.md") - data, err := os.ReadFile(archPath) - if err != nil { - return "" // Not found or unreadable - gracefully skip - } - return string(data) -} - -// PolicyConstraint represents a constraint extracted from a policy file. -type PolicyConstraint struct { - Name string // Policy file name - Description string // Extracted description from comments - Rules []string // Rule names (deny, warn) -} - -// loadPolicyConstraints loads policy files and extracts constraint descriptions. -// It respects the token budget to prevent context overflow. -func loadPolicyConstraints(basePath string) ([]PolicyConstraint, string) { - if basePath == "" { - return nil, "" - } - - // Determine policies path - policiesPath := policy.GetPoliciesPath(filepath.Dir(basePath)) // basePath is .taskwing/memory, go up one level - loader := policy.NewLoader(afero.NewOsFs(), policiesPath) - - policies, err := loader.LoadAll() - if err != nil || len(policies) == 0 { - return nil, "" - } - - var constraints []PolicyConstraint - var totalTokens int - - for _, p := range policies { - constraint := extractPolicyConstraint(p) - if constraint.Description == "" && len(constraint.Rules) == 0 { - continue - } - - // Estimate tokens for this constraint - constraintText := formatPolicyConstraint(constraint) - tokens := llm.EstimateTokens(constraintText) - - // Check budget - if totalTokens+tokens > PolicyConstraintsBudget { - break // Stop adding policies if budget exceeded - } - - constraints = append(constraints, constraint) - totalTokens += tokens - } - - if len(constraints) == 0 { - return nil, "" - } - - // Format all constraints - var sb strings.Builder - sb.WriteString("## POLICY CONSTRAINTS\n") - sb.WriteString("The following policies are enforced. Plans MUST comply with these rules.\n\n") - - for _, c := range constraints { - sb.WriteString(formatPolicyConstraint(c)) - } - - return constraints, sb.String() -} - -// extractPolicyConstraint extracts metadata from a policy file. -// It looks for: -// - Header comments (# Description: ...) -// - Package description comments -// - Rule names (deny, warn) -func extractPolicyConstraint(p *policy.PolicyFile) PolicyConstraint { - constraint := PolicyConstraint{ - Name: p.Name, - } - - lines := strings.Split(p.Content, "\n") - - // Extract description from header comments - var descLines []string - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "#") { - // Extract comment content - comment := strings.TrimPrefix(trimmed, "#") - comment = strings.TrimSpace(comment) - - // Skip empty comments and metadata markers - if comment == "" || strings.HasPrefix(comment, "!") { - continue - } - - // Skip package/import comments - if strings.HasPrefix(strings.ToLower(comment), "package") { - continue - } - - descLines = append(descLines, comment) - } else if trimmed != "" && !strings.HasPrefix(trimmed, "package") && !strings.HasPrefix(trimmed, "import") { - // Stop at first non-comment, non-empty line - break - } - } - - if len(descLines) > 0 { - constraint.Description = strings.Join(descLines, " ") - // Truncate long descriptions - if len(constraint.Description) > 200 { - constraint.Description = constraint.Description[:197] + "..." - } - } - - // Extract rule names using regex - denyPattern := regexp.MustCompile(`deny\s+contains`) - warnPattern := regexp.MustCompile(`warn\s+contains`) - - if denyPattern.MatchString(p.Content) { - constraint.Rules = append(constraint.Rules, "deny") - } - if warnPattern.MatchString(p.Content) { - constraint.Rules = append(constraint.Rules, "warn") - } - - return constraint -} - -// formatPolicyConstraint formats a single policy constraint for context injection. -func formatPolicyConstraint(c PolicyConstraint) string { - var sb strings.Builder - sb.WriteString(fmt.Sprintf("### Policy: %s\n", c.Name)) - - if c.Description != "" { - sb.WriteString(fmt.Sprintf("%s\n", c.Description)) - } - - if len(c.Rules) > 0 { - sb.WriteString(fmt.Sprintf("Rules: %s\n", strings.Join(c.Rules, ", "))) - } - - sb.WriteString("\n") - return sb.String() -} - -// RetrieveContext performs the standard context retrieval for planning and evaluation. -// It ensures that both the interactive CLI and the evaluation system use the exact same logic. -// If memoryBasePath is provided, it will also inject the ARCHITECTURE.md content. -func RetrieveContext(ctx context.Context, ks *knowledge.Service, goal string, memoryBasePath string) (SearchStrategyResult, error) { - var searchLog []string - - // === NEW: Load comprehensive ARCHITECTURE.md if available === - archContent := loadArchitectureMD(memoryBasePath) - if archContent != "" { - searchLog = append(searchLog, "✓ Loaded ARCHITECTURE.md") - } - - // === NEW: Load Policy Constraints (OPA-based) === - policyConstraints, policySection := loadPolicyConstraints(memoryBasePath) - if len(policyConstraints) > 0 { - searchLog = append(searchLog, fmt.Sprintf("✓ Loaded %d policy constraints", len(policyConstraints))) - } - - // === 0. Fetch Constraints Explicitly === - // Always retrieve 'constraint' type nodes, regardless of goal. - // These represent mandatory rules and must be highlighted. - // QA FIX: Use ListNodesByType to avoid semantic filtering. - constraintNodes, _ := ks.ListNodesByType(ctx, memory.NodeTypeConstraint) - - // 1. Strategize: Generate search queries - queries, err := ks.SuggestContextQueries(ctx, goal) - if err != nil { - queries = []string{goal, "Technology Stack"} - } - - // 2. Execute Searches - uniqueNodes := make(map[string]knowledge.ScoredNode) - - for _, q := range queries { - // Search (this uses the hybrid FTS + Vector approach defined in knowledge.Service) - nodes, _ := ks.Search(ctx, q, 3) // Limit 3 per query - - for _, sn := range nodes { - // Deduplicate by ID - if _, exists := uniqueNodes[sn.Node.ID]; !exists { - uniqueNodes[sn.Node.ID] = sn - } else { - // Keep higher score - if sn.Score > uniqueNodes[sn.Node.ID].Score { - uniqueNodes[sn.Node.ID] = sn - } - } - } - searchLog = append(searchLog, fmt.Sprintf("• Checking memory for: '%s'", q)) - } - - // 3. Format Context - var sb strings.Builder - - // === NEW: Include ARCHITECTURE.md first (most comprehensive context) === - if archContent != "" { - sb.WriteString("## PROJECT ARCHITECTURE OVERVIEW\n") - sb.WriteString("Consolidated architecture document for this codebase:\n\n") - sb.WriteString(archContent) - sb.WriteString("\n---\n\n") - } - - // === NEW: Include Policy Constraints (OPA-based guardrails) === - if policySection != "" { - sb.WriteString(policySection) - sb.WriteString("\n---\n\n") - } - - // === Format Constraints (highlighted separately for emphasis) === - if len(constraintNodes) > 0 { - sb.WriteString("## MANDATORY ARCHITECTURAL CONSTRAINTS\n") - sb.WriteString("These rules MUST be obeyed by all generated tasks.\n\n") - for _, n := range constraintNodes { - sb.WriteString(fmt.Sprintf("- **%s**: %s\n", n.Summary, n.Text())) - } - sb.WriteString("\n") - // Prepend to search log so it appears first - searchLog = append([]string{"✓ Loaded mandatory constraints."}, searchLog...) - } - - sb.WriteString("## RELEVANT ARCHITECTURAL CONTEXT\n") - - // Sort by Score - var allNodes []knowledge.ScoredNode - for _, sn := range uniqueNodes { - allNodes = append(allNodes, sn) - } - sort.Slice(allNodes, func(i, j int) bool { - return allNodes[i].Score > allNodes[j].Score - }) - - for _, node := range allNodes { - sb.WriteString(fmt.Sprintf("### [%s] %s\n%s\n", node.Node.Type, node.Node.Summary, node.Node.Text())) - - // Append evidence file paths if available (Phase 2 feature) - if node.Node.Evidence != "" { - var evidenceList []struct { - FilePath string `json:"file_path"` - StartLine int `json:"start_line"` - } - if json.Unmarshal([]byte(node.Node.Evidence), &evidenceList) == nil && len(evidenceList) > 0 { - sb.WriteString("Referenced files: ") - for i, ev := range evidenceList { - if i > 0 { - sb.WriteString(", ") - } - if ev.StartLine > 0 { - sb.WriteString(fmt.Sprintf("%s:L%d", ev.FilePath, ev.StartLine)) - } else { - sb.WriteString(ev.FilePath) - } - } - sb.WriteString("\n") - } - } - sb.WriteString("\n") - } - - // Format strategy log - var strategyLog strings.Builder - strategyLog.WriteString("Research Strategy:\n") - for _, log := range searchLog { - strategyLog.WriteString(" " + log + "\n") - } - - return SearchStrategyResult{ - Context: sb.String(), - Strategy: strategyLog.String(), - }, nil -} diff --git a/internal/agents/impl/react_bootstrap.go b/internal/agents/impl/react_bootstrap.go new file mode 100644 index 0000000..f6a0461 --- /dev/null +++ b/internal/agents/impl/react_bootstrap.go @@ -0,0 +1,80 @@ +/* +Package impl provides the shared ReAct helper for bootstrap agents. + +runReactMode wraps Eino's react.Agent wiring so that any bootstrap agent +(doc, deps, git) can attempt tool-calling exploration before falling back +to its deterministic path. +*/ +package impl + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/flow/agent/react" + "github.com/cloudwego/eino/schema" + agenttools "github.com/josephgoksu/TaskWing/internal/agents/tools" + "github.com/josephgoksu/TaskWing/internal/llm" +) + +// ErrNoToolCalling indicates the configured model does not support tool calling. +// Callers should fall back to the deterministic analysis path. +var ErrNoToolCalling = errors.New("model does not support tool calling") + +// Minimum findings thresholds for ReAct to be considered successful. +// If ReAct produces fewer findings than these thresholds, we fall through +// to the deterministic path which is more thorough for small/focused repos. +const ( + reactMinFindingsDoc = 5 // Doc deterministic produces 10-20 via parallel tracks + reactMinFindingsDeps = 3 // Deps deterministic produces 5-10 + reactMinFindingsGit = 3 // Git deterministic produces 3-8 via chunked analysis +) + +// runReactMode runs a ReAct agent with the given system prompt and user message. +// It creates a short-lived chat model, wires up Eino tools, and returns the +// raw text output from the agent. If the model lacks tool-calling support, +// ErrNoToolCalling is returned so callers can fall through to deterministic mode. +func runReactMode(ctx context.Context, cfg llm.Config, basePath, systemPrompt, userMsg string, maxSteps int) (string, time.Duration, error) { + start := time.Now() + + closeableChatModel, err := llm.NewCloseableChatModel(ctx, cfg) + if err != nil { + return "", 0, fmt.Errorf("create chat model: %w", err) + } + defer func() { _ = closeableChatModel.Close() }() + + toolCallingModel, ok := closeableChatModel.BaseChatModel.(model.ToolCallingChatModel) + if !ok { + return "", 0, ErrNoToolCalling + } + + einoTools := agenttools.CreateEinoTools(basePath) + baseTools := make([]tool.BaseTool, len(einoTools)) + for i, t := range einoTools { + baseTools[i] = t + } + + agent, err := react.NewAgent(ctx, &react.AgentConfig{ + ToolCallingModel: toolCallingModel, + ToolsConfig: compose.ToolsNodeConfig{Tools: baseTools}, + MaxStep: maxSteps, + MessageModifier: func(_ context.Context, msgs []*schema.Message) []*schema.Message { + return append([]*schema.Message{schema.SystemMessage(systemPrompt)}, msgs...) + }, + }) + if err != nil { + return "", time.Since(start), fmt.Errorf("create ReAct agent: %w", err) + } + + resp, err := agent.Generate(ctx, []*schema.Message{schema.UserMessage(userMsg)}) + if err != nil { + return "", time.Since(start), fmt.Errorf("agent generate: %w", err) + } + + return resp.Content, time.Since(start), nil +} diff --git a/internal/agents/tools/eino.go b/internal/agents/tools/eino.go index f735121..62be438 100644 --- a/internal/agents/tools/eino.go +++ b/internal/agents/tools/eino.go @@ -73,7 +73,14 @@ func (t *ReadFileTool) InvokableRun(ctx context.Context, argsJSON string, opts . if err != nil { return "", err } - content, err := os.ReadFile(filepath.Join(t.basePath, cleanPath)) + fullPath := filepath.Join(t.basePath, cleanPath) + + // Check if path is a directory -- LLMs sometimes call read_file on dirs + if info, statErr := os.Stat(fullPath); statErr == nil && info.IsDir() { + return fmt.Sprintf("%s is a directory, not a file. Use list_dir to explore directories.", cleanPath), nil + } + + content, err := os.ReadFile(fullPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return fmt.Sprintf("File not found: %s", cleanPath), nil @@ -155,7 +162,7 @@ func (t *GrepTool) InvokableRun(ctx context.Context, argsJSON string, opts ...to } grepArgs = append(grepArgs, "--exclude-dir=node_modules", "--exclude-dir=vendor", "--exclude-dir=.git", "--exclude-dir=dist", "--exclude-dir=build") - grepArgs = append(grepArgs, args.Pattern, searchPath) + grepArgs = append(grepArgs, "--", args.Pattern, searchPath) cmd := exec.CommandContext(ctx, "grep", grepArgs...) var stdout bytes.Buffer @@ -346,6 +353,16 @@ func (t *ExecTool) InvokableRun(ctx context.Context, argsJSON string, opts ...to return "", fmt.Errorf("command '%s' not allowed. Allowed: %v", args.Command, allowed) } + // Block dangerous find flags that allow arbitrary command execution + if args.Command == "find" { + for _, a := range args.Args { + lower := strings.ToLower(a) + if lower == "-exec" || lower == "-execdir" || lower == "-delete" || lower == "-ok" || lower == "-okdir" { + return "", fmt.Errorf("find flag '%s' is not allowed for security reasons", a) + } + } + } + cmd := exec.CommandContext(ctx, args.Command, args.Args...) cmd.Dir = t.basePath var stdout, stderr bytes.Buffer diff --git a/internal/agents/verification/agent.go b/internal/agents/verification/agent.go index 251a1dc..e0c4498 100644 --- a/internal/agents/verification/agent.go +++ b/internal/agents/verification/agent.go @@ -371,4 +371,3 @@ func FilterVerifiedFindings(findings []core.Finding) []core.Finding { } return result } - diff --git a/internal/app/ask.go b/internal/app/ask.go index 0a9f73b..5f4c85c 100644 --- a/internal/app/ask.go +++ b/internal/app/ask.go @@ -3,6 +3,7 @@ package app import ( "context" "encoding/json" + "errors" "fmt" "io" "log" @@ -455,9 +456,14 @@ func (a *AskApp) generateRAGAnswer(ctx context.Context, query string, nodes []kn retrievedContext := strings.Join(contextParts, "\n\n") prompt := fmt.Sprintf(`You are an expert on this codebase. Answer the user's question using ONLY the context below. -The context includes both project documentation/decisions AND actual source code. -When referencing code, cite the file and line numbers. -Be concise and direct. +The context includes project documentation, architectural decisions, constraints, patterns, and source code. + +Guidelines: +- Structure your answer clearly with sections when the question is broad (e.g., architecture overviews) +- When referencing code, cite the file and line numbers +- Include the "why" behind decisions, not just the "what" +- Mention relevant constraints that affect the answer +- Be thorough but avoid repeating information %s @@ -491,7 +497,7 @@ Be concise and direct. var fullAnswer strings.Builder for { chunk, err := stream.Recv() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { @@ -528,6 +534,11 @@ func suppressStdLogger() func() { // annotateResultFreshness runs Level 1 freshness checks on each result // and populates the FreshnessStatus, FreshnessNote, and StaleFiles fields. // This runs inline on every MCP query (<1ms per result). +// +// TODO(freshness-level2): Pass repo to persist last_verified_at and +// original_confidence after each check, enabling "[verified Xh ago]" display. +// TODO(freshness-level2): Use stored last_verified_at as reference time +// instead of CreatedAt for more accurate staleness after re-verification. func annotateResultFreshness(basePath string, results []knowledge.NodeResponse) { for i := range results { node := &results[i] @@ -556,7 +567,8 @@ func annotateResultFreshness(basePath string, results []knowledge.NodeResponse) // node was created indicate the knowledge may be stale. refTime := node.CreatedAt if refTime.IsZero() { - // Fallback: if no creation time, treat as stale to be safe + // Fallback: if no creation time, treat as stale to be safe. + // This can happen with imported or pre-v2.3 nodes missing created_at. refTime = time.Now().Add(-24 * time.Hour) } diff --git a/internal/app/explain.go b/internal/app/explain.go index cc94309..a2de6a2 100644 --- a/internal/app/explain.go +++ b/internal/app/explain.go @@ -3,6 +3,7 @@ package app import ( "context" + "errors" "fmt" "io" "sort" @@ -315,7 +316,7 @@ func (a *ExplainApp) generateExplanation(ctx context.Context, result *ExplainRes var fullAnswer strings.Builder for { chunk, err := stream.Recv() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { diff --git a/internal/app/plan.go b/internal/app/plan.go index e6a1a00..ebaff54 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -72,10 +72,10 @@ type GenerateResult struct { // GenerateOptions configures the behavior of plan generation. type GenerateOptions struct { - Goal string // Original user goal - ClarifySessionID string // Required: clarify session that reached ready state - EnrichedGoal string // Fully clarified specification - Save bool // Whether to persist plan/tasks to DB + Goal string // Original user goal + ClarifySessionID string // Required: clarify session that reached ready state + EnrichedGoal string // Fully clarified specification + Save bool // Whether to persist plan/tasks to DB ExplicitTasks []task.TaskInput // If provided, use these instead of LLM generation } @@ -130,9 +130,8 @@ type PlanApp struct { Repo task.Repository ClarifierFactory func(llm.Config) GoalsClarifier PlannerFactory func(llm.Config) TaskPlanner - ContextRetriever func(ctx context.Context, ks *knowledge.Service, goal, memoryPath string) (impl.SearchStrategyResult, error) - // TaskEnricher executes ask queries to populate task ContextSummary. - // If nil, tasks will not have embedded context (legacy behavior). + // TaskEnricher populates task ContextSummary at creation time. + // Uses GetProjectContext with compact options by default. TaskEnricher TaskContextEnricher } @@ -147,58 +146,55 @@ func NewPlanApp(ctx *Context) *PlanApp { PlannerFactory: func(cfg llm.Config) TaskPlanner { return impl.NewPlanningAgent(cfg) }, - ContextRetriever: impl.RetrieveContext, } - // Initialize default TaskEnricher using AskApp pa.TaskEnricher = pa.defaultTaskEnricher return pa } -// defaultTaskEnricher executes all ask queries and aggregates results into a context summary. -// This is the production implementation; tests can override TaskEnricher for mocking. +// retrieveContext performs unified project context retrieval for planning. +// Returns the formatted context string ready for LLM prompt injection. +func (a *PlanApp) retrieveContext(ctx context.Context, ks *knowledge.Service, goal, memoryPath string) (string, error) { + opts := knowledge.DefaultContextOptions() + opts.Query = goal + opts.MemoryBasePath = memoryPath + + pc, err := knowledge.GetProjectContext(ctx, ks, opts) + if err != nil { + return "", err + } + + return pc.Format(), nil +} + +// defaultTaskEnricher uses GetProjectContext with compact options to enrich tasks. func (a *PlanApp) defaultTaskEnricher(ctx context.Context, queries []string) (string, error) { - if len(queries) == 0 { + if a.ctx == nil || a.ctx.Repo == nil { return "", nil } - askApp := NewAskApp(a.ctx) - var contextParts []string + ks := knowledge.NewService(a.ctx.Repo, a.ctx.LLMCfg) - for _, query := range queries { - result, err := askApp.Query(ctx, query, AskOptions{ - Limit: 3, // 3 results per query - GenerateAnswer: false, - IncludeSymbols: false, // Keep context focused on knowledge, not symbols - NoRewrite: true, // Skip rewriting for speed - }) - if err != nil { - slog.Debug("task enrichment query failed", "query", query, "error", err) - continue - } - - if len(result.Results) > 0 { - var parts []string - for _, node := range result.Results { - // Format: "- **Summary** (type): Content preview" - // Truncate to 300 chars (consistent with presentation.go) - content := node.Content - if len(content) > 300 { - content = content[:297] + "..." - } - parts = append(parts, fmt.Sprintf("- **%s** (%s): %s", node.Summary, node.Type, content)) - } - if len(parts) > 0 { - contextParts = append(contextParts, strings.Join(parts, "\n")) - } - } + // Use the task's specific queries as the search query, or fall back to baseline + query := "project constraints and key technology decisions" + if len(queries) > 0 { + query = strings.Join(queries, " ") } - if len(contextParts) == 0 { - return "", nil + opts := knowledge.DefaultContextOptions() + opts.Query = query + opts.IncludeArchitectureMD = false // Too large for per-task context + opts.MaxNodes = 8 // Compact for task embedding + opts.UseLLMQueries = false // Use queries directly for speed + + memoryPath, _ := config.GetMemoryBasePath() + opts.MemoryBasePath = memoryPath + + pc, err := knowledge.GetProjectContext(ctx, ks, opts) + if err != nil || (len(pc.Constraints) == 0 && len(pc.RelevantNodes) == 0) { + return "", err } - // Header matches presentation.go late binding for consistency - return "## Relevant Architecture Context\n" + strings.Join(contextParts, "\n"), nil + return pc.FormatCompact(), nil } // Clarify refines a development goal by asking clarifying questions. @@ -313,8 +309,8 @@ func (a *PlanApp) Clarify(ctx context.Context, opts ClarifyOptions) (*ClarifyRes ks := knowledge.NewService(a.ctx.Repo, llmCfg) var contextStr string if memoryPath, err := config.GetMemoryBasePath(); err == nil { - if result, err := a.ContextRetriever(ctx, ks, goal, memoryPath); err == nil { - contextStr = result.Context + if retrievedCtx, err := a.retrieveContext(ctx, ks, goal, memoryPath); err == nil { + contextStr = retrievedCtx } } @@ -571,8 +567,8 @@ func (a *PlanApp) Generate(ctx context.Context, opts GenerateOptions) (*Generate ks := knowledge.NewService(a.ctx.Repo, llmCfg) var contextStr string if memoryPath, err := config.GetMemoryBasePath(); err == nil { - if result, err := a.ContextRetriever(ctx, ks, opts.EnrichedGoal, memoryPath); err == nil { - contextStr = result.Context + if retrievedCtx, err := a.retrieveContext(ctx, ks, opts.EnrichedGoal, memoryPath); err == nil { + contextStr = retrievedCtx } } @@ -1370,8 +1366,8 @@ func (a *PlanApp) Decompose(ctx context.Context, opts DecomposeOptions) (*Decomp ks := knowledge.NewService(a.ctx.Repo, llmCfg) var contextStr string if memoryPath, err := config.GetMemoryBasePath(); err == nil { - if result, err := a.ContextRetriever(ctx, ks, opts.EnrichedGoal, memoryPath); err == nil { - contextStr = result.Context + if retrievedCtx, err := a.retrieveContext(ctx, ks, opts.EnrichedGoal, memoryPath); err == nil { + contextStr = retrievedCtx } } @@ -1544,8 +1540,8 @@ func (a *PlanApp) Expand(ctx context.Context, opts ExpandOptions) (*ExpandResult ks := knowledge.NewService(a.ctx.Repo, llmCfg) var contextStr string if memoryPath, err := config.GetMemoryBasePath(); err == nil { - if result, err := a.ContextRetriever(ctx, ks, phase.Title+" "+phase.Description, memoryPath); err == nil { - contextStr = result.Context + if retrievedCtx, err := a.retrieveContext(ctx, ks, phase.Title+" "+phase.Description, memoryPath); err == nil { + contextStr = retrievedCtx } } diff --git a/internal/app/task.go b/internal/app/task.go index dae400a..67d7a49 100644 --- a/internal/app/task.go +++ b/internal/app/task.go @@ -103,7 +103,7 @@ func (a *TaskApp) Next(ctx context.Context, opts TaskNextOptions) (*TaskResult, if activePlan == nil { return &TaskResult{ Success: false, - Message: "No active plan found. Create one with 'taskwing goal \"\"'.", + Message: "No active plan found. Use /taskwing:plan to create one.", }, nil } planID = activePlan.ID @@ -119,7 +119,7 @@ func (a *TaskApp) Next(ctx context.Context, opts TaskNextOptions) (*TaskResult, return &TaskResult{ Success: true, Message: "No pending tasks in this plan. All tasks may be completed or blocked.", - Hint: "Check plan status with 'taskwing plan list' or create new tasks.", + Hint: "Use task MCP tool with action=current to check progress, or /taskwing:context for full status.", }, nil } diff --git a/internal/bootstrap/bootstrap_repro_test.go b/internal/bootstrap/bootstrap_repro_test.go index 0b5457b..d69cc06 100644 --- a/internal/bootstrap/bootstrap_repro_test.go +++ b/internal/bootstrap/bootstrap_repro_test.go @@ -197,9 +197,9 @@ func TestBootstrapRepro_GitLogExit128(t *testing.T) { // Reproduce: git log fails with exit 128 when run against a non-git directory tests := []struct { - name string - workDir string - responses map[string]mockResponse + name string + workDir string + responses map[string]mockResponse wantIsRepo bool }{ { @@ -301,10 +301,10 @@ func TestBootstrapRepro_ZeroDocsLoaded(t *testing.T) { // but sub-repos contain documentation tests := []struct { - name string - setup func(t *testing.T, dir string) - wantMin int // minimum expected doc count - wantZero bool + name string + setup func(t *testing.T, dir string) + wantMin int // minimum expected doc count + wantZero bool }{ { name: "empty directory yields zero docs", diff --git a/internal/bootstrap/factory.go b/internal/bootstrap/factory.go index adb7495..cb3f329 100644 --- a/internal/bootstrap/factory.go +++ b/internal/bootstrap/factory.go @@ -1,17 +1,90 @@ package bootstrap import ( + "os" + "path/filepath" + "github.com/josephgoksu/TaskWing/internal/agents/core" "github.com/josephgoksu/TaskWing/internal/agents/impl" "github.com/josephgoksu/TaskWing/internal/llm" + "github.com/josephgoksu/TaskWing/internal/safepath" ) // NewDefaultAgents returns the standard set of agents for a bootstrap run. -func NewDefaultAgents(cfg llm.Config, projectPath string) []core.Agent { - return []core.Agent{ - impl.NewDocAgent(cfg), - impl.NewCodeAgent(cfg, projectPath), - impl.NewGitAgent(cfg), - impl.NewDepsAgent(cfg), +// If snap is non-nil, agents are adaptively selected based on project state: +// - deps is skipped if no dependency files exist +// - code is skipped if no source files exist +// - git is skipped if the project is not a git repo or has 0 commits +// +// Pass nil for snap to include all agents (safe default). +func NewDefaultAgents(cfg llm.Config, projectPath string, snap *Snapshot) []core.Agent { + var agents []core.Agent + + // Doc agent is always included (markdown almost always exists) + agents = append(agents, impl.NewDocAgent(cfg)) + + // Deps agent: skip if no dependency manifests found + if snap == nil || hasDependencyFiles(projectPath) { + agents = append(agents, impl.NewDepsAgent(cfg)) + } + + // Code agent: skip if snapshot says zero source files + if snap == nil || snap.FileCount > 0 { + agents = append(agents, impl.NewCodeAgent(cfg, projectPath)) + } + + // Git agent: skip if not a git repo + if snap == nil || snap.IsGitRepo { + agents = append(agents, impl.NewGitAgent(cfg)) + } + + return agents +} + +// dependencyManifests lists common dependency file names across ecosystems. +var dependencyManifests = []string{ + "package.json", + "go.mod", + "Cargo.toml", + "requirements.txt", + "Pipfile", + "pyproject.toml", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "Gemfile", + "composer.json", + "pubspec.yaml", +} + +// hasDependencyFiles checks if any common dependency manifest exists at the project root. +// Uses safepath.SafeJoin to prevent path traversal in basePath. +func hasDependencyFiles(basePath string) bool { + for _, name := range dependencyManifests { + p, err := safepath.SafeJoin(basePath, name) + if err != nil { + continue + } + if _, err := os.Stat(p); err == nil { + return true + } + } + // Also check for dependency files in subdirectories (monorepo) + if entries, err := os.ReadDir(basePath); err == nil { + for _, e := range entries { + if !e.IsDir() { + continue + } + for _, name := range dependencyManifests { + p, err := safepath.SafeJoin(basePath, filepath.Join(e.Name(), name)) + if err != nil { + continue + } + if _, err := os.Stat(p); err == nil { + return true + } + } + } } + return false } diff --git a/internal/bootstrap/initializer.go b/internal/bootstrap/initializer.go index 27537bb..6c06b72 100644 --- a/internal/bootstrap/initializer.go +++ b/internal/bootstrap/initializer.go @@ -11,6 +11,9 @@ import ( "sort" "strings" "time" + + "github.com/josephgoksu/TaskWing/internal/safepath" + "github.com/josephgoksu/TaskWing/skills" ) // Initializer handles the setup of TaskWing project structure and integrations. @@ -238,10 +241,11 @@ type aiHelperConfig struct { singleFile bool // If true, generate a single file instead of directory with multiple files singleFileName string // Filename for single-file mode (e.g., "copilot-instructions.md") skillsDir bool // If true, use OpenCode-style skills directory structure + claudeSkills bool // If true, generate .claude/commands/taskwing/ with embedded content } var aiCatalog = []aiHelperConfig{ - {name: "claude", displayName: "Claude Code", commandsDir: ".claude/commands", fileExt: ".md", singleFile: false}, + {name: "claude", displayName: "Claude Code", commandsDir: ".claude/commands", fileExt: ".md", claudeSkills: true}, {name: "cursor", displayName: "Cursor", commandsDir: ".cursor/rules", fileExt: ".md", singleFile: false}, {name: "gemini", displayName: "Gemini CLI", commandsDir: ".gemini/commands", fileExt: ".toml", singleFile: false}, {name: "codex", displayName: "OpenAI Codex", commandsDir: ".codex/commands", fileExt: ".md", singleFile: false}, @@ -293,18 +297,13 @@ type SlashCommand struct { // SlashCommands is the canonical list of slash commands generated by TaskWing. // When this list changes, the version hash changes, triggering updates on next bootstrap. var SlashCommands = []SlashCommand{ - {"taskwing:ask", "ask", "Use when you need to search project knowledge (decisions, patterns, constraints)."}, - {"taskwing:remember", "remember", "Use when you want to persist a decision, pattern, or insight to project memory."}, + {"taskwing:plan", "plan", "Use when you need to clarify a goal and build an approved execution plan."}, {"taskwing:next", "next", "Use when you are ready to start the next approved TaskWing task with full context."}, {"taskwing:done", "done", "Use when implementation is verified and you are ready to complete the current task."}, - {"taskwing:status", "status", "Use when you need current task progress and acceptance criteria status."}, - {"taskwing:plan", "plan", "Use when you need to clarify a goal and build an approved execution plan."}, - {"taskwing:debug", "debug", "Use when an issue requires root-cause-first debugging before proposing fixes."}, - {"taskwing:explain", "explain", "Use when you need a deep explanation of a code symbol and its call graph."}, - {"taskwing:simplify", "simplify", "Use when you want to simplify code while preserving behavior."}, + {"taskwing:context", "context", "Use when you need the full project knowledge dump for complete architectural context."}, } -// SlashCommandNames returns slash command short names (e.g., "ask", "next", "done"), in canonical order. +// SlashCommandNames returns slash command short names (e.g., "plan", "next", "done"), in canonical order. func SlashCommandNames() []string { names := make([]string, 0, len(SlashCommands)) for _, cmd := range SlashCommands { @@ -340,17 +339,14 @@ func MCPToolNames() []string { // CoreCommand describes a CLI command included in documentation. type CoreCommand struct { - Display string `json:"display"` // e.g. "taskwing goal \"\"" + Display string `json:"display"` // e.g. "taskwing bootstrap" } // CoreCommands is the curated list of CLI commands shown in documentation. var CoreCommands = []CoreCommand{ {"taskwing bootstrap"}, - {"taskwing goal \"\""}, {"taskwing ask \"\""}, {"taskwing task"}, - {"taskwing plan status"}, - {"taskwing slash"}, {"taskwing mcp"}, {"taskwing doctor"}, {"taskwing config"}, @@ -372,6 +368,9 @@ func AIToolConfigVersion(aiName string) string { parts = append(parts, fmt.Sprintf("ext:%s", cfg.fileExt)) parts = append(parts, fmt.Sprintf("singleFile:%t", cfg.singleFile)) parts = append(parts, fmt.Sprintf("singleFileName:%s", cfg.singleFileName)) + // Generation format marker: bump this to force regeneration when + // the content generation method changes (e.g., shell-out to embedded). + parts = append(parts, "gen:embedded-v1") for _, cmd := range SlashCommands { parts = append(parts, fmt.Sprintf("cmd:%s:%s:%s", cmd.BaseName, cmd.SlashCmd, cmd.Description)) @@ -404,7 +403,7 @@ func ExpectedCommandCount() int { } // slashCommandNamespace is the subdirectory name used for namespaced slash commands. -// e.g., .claude/commands/taskwing/ask.md → /taskwing:ask +// e.g., .claude/commands/taskwing/plan.md → /taskwing:plan const slashCommandNamespace = "taskwing" func expectedSlashCommandFiles(ext string) map[string]struct{} { @@ -416,13 +415,20 @@ func expectedSlashCommandFiles(ext string) map[string]struct{} { return expected } +// deprecatedSlashCommands lists command names that were removed but whose files +// may still exist from older bootstrap runs. These are always pruned. +var deprecatedSlashCommands = []string{"debug", "simplify", "brief", "ask", "remember", "status", "explain"} + func managedSlashCommandBases() map[string]struct{} { - managed := make(map[string]struct{}, len(SlashCommands)*2) + managed := make(map[string]struct{}, len(SlashCommands)*2+len(deprecatedSlashCommands)*2) for _, cmd := range SlashCommands { managed[cmd.SlashCmd] = struct{}{} - // Also recognize legacy tw-* names for migration cleanup managed["tw-"+cmd.SlashCmd] = struct{}{} } + for _, name := range deprecatedSlashCommands { + managed[name] = struct{}{} + managed["tw-"+name] = struct{}{} + } return managed } @@ -509,6 +515,11 @@ func (i *Initializer) CreateSlashCommands(aiName string, verbose bool) error { return i.createSingleFileInstructions(aiName, verbose) } + // Handle Claude Code: .claude/commands/taskwing/.md with embedded content + if cfg.claudeSkills { + return i.createClaudeSkills(verbose) + } + // Handle OpenCode commands directory structure // OpenCode commands: .opencode/commands/.md (flat structure) // See: https://opencode.ai/docs/commands/ @@ -534,30 +545,84 @@ func (i *Initializer) CreateSlashCommands(aiName string, verbose bool) error { isTOML := cfg.fileExt == ".toml" // Create namespace subdirectory (e.g., .claude/commands/taskwing/) - // This produces namespaced commands like /taskwing:ask, /taskwing:next + // This produces namespaced commands like /taskwing:plan, /taskwing:next nsDir := filepath.Join(commandsDir, slashCommandNamespace) if err := os.MkdirAll(nsDir, 0755); err != nil { return fmt.Errorf("create namespace dir %s: %w", slashCommandNamespace, err) } for _, cmd := range SlashCommands { + // Embed skill content directly from the skills package + body, err := skills.GetBody(cmd.SlashCmd) + if err != nil { + return fmt.Errorf("load skill content for %s: %w", cmd.SlashCmd, err) + } + var content, fileName string if isTOML { + // Escape triple quotes in body for TOML + escapedBody := strings.ReplaceAll(body, `"""`, `\"\"\"`) fileName = cmd.SlashCmd + ".toml" - content = fmt.Sprintf(`description = "%s" - -prompt = """!{taskwing slash %s}""" -`, cmd.Description, cmd.SlashCmd) + content = fmt.Sprintf("description = %q\n\nprompt = \"\"\"%s\"\"\"\n", cmd.Description, escapedBody) } else { fileName = cmd.SlashCmd + ".md" - content = fmt.Sprintf(`--- -description: %s ---- -!taskwing slash %s -`, cmd.Description, cmd.SlashCmd) + content = fmt.Sprintf("---\ndescription: %s\n---\n%s\n", cmd.Description, body) + } + + filePath := filepath.Join(nsDir, fileName) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return fmt.Errorf("create %s: %w", fileName, err) + } + if verbose { + fmt.Printf(" ✓ Created %s/%s/%s\n", cfg.commandsDir, slashCommandNamespace, fileName) + } + } + + if err := pruneStaleSlashCommands(commandsDir, cfg.fileExt, verbose); err != nil { + return err + } + + return nil +} + +// createClaudeSkills generates .claude/commands/taskwing/.md with embedded content. +// Embeds the full prompt content directly from the skills package. +// Uses the commands namespace system: .claude/commands/taskwing/next.md -> /taskwing:next +func (i *Initializer) createClaudeSkills(verbose bool) error { + cfg := aiHelpers["claude"] + commandsDir := filepath.Join(i.basePath, cfg.commandsDir) + if err := os.MkdirAll(commandsDir, 0755); err != nil { + return fmt.Errorf("create commands dir: %w", err) + } + + // Write marker file + configVersion := AIToolConfigVersion("claude") + markerPath := filepath.Join(commandsDir, TaskWingManagedFile) + markerContent := fmt.Sprintf("# This directory is managed by TaskWing\n# Created: %s\n# AI: claude\n# Version: %s\n", + time.Now().UTC().Format(time.RFC3339), configVersion) + if err := os.WriteFile(markerPath, []byte(markerContent), 0644); err != nil { + return fmt.Errorf("create marker file: %w", err) + } + + // Create namespace subdirectory: .claude/commands/taskwing/ + // This produces /taskwing:plan, /taskwing:next, etc. + nsDir := filepath.Join(commandsDir, slashCommandNamespace) + if err := os.MkdirAll(nsDir, 0755); err != nil { + return fmt.Errorf("create namespace dir %s: %w", slashCommandNamespace, err) + } + + for _, cmd := range SlashCommands { + // Read embedded content from the skills package + body, err := skills.GetBody(cmd.SlashCmd) + if err != nil { + return fmt.Errorf("read embedded skill %s: %w", cmd.SlashCmd, err) } + // Write as command file with frontmatter (description for Claude Code discovery) + fileName := cmd.SlashCmd + ".md" + content := fmt.Sprintf("---\ndescription: %s\n---\n%s", cmd.Description, body) + filePath := filepath.Join(nsDir, fileName) if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { return fmt.Errorf("create %s: %w", fileName, err) @@ -571,6 +636,25 @@ description: %s return err } + // Clean up intermediate .claude/skills/tw-*/ directories (from development builds) + skillsDir := filepath.Join(i.basePath, ".claude", "skills") + if entries, err := os.ReadDir(skillsDir); err == nil { + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(e.Name(), "tw-") { + p, err := safepath.SafeJoin(skillsDir, e.Name()) + if err != nil { + continue + } + _ = os.RemoveAll(p) + if verbose { + fmt.Printf(" ✓ Removed intermediate skill %s\n", e.Name()) + } + } + } + // Remove marker from skills dir if present + _ = os.Remove(filepath.Join(skillsDir, TaskWingManagedFile)) + } + return nil } @@ -744,14 +828,13 @@ func (i *Initializer) createOpenCodeCommands(aiName string, verbose bool) error return fmt.Errorf("invalid OpenCode command name '%s': must match ^[a-z0-9]+(-[a-z0-9]+)*$ (lowercase alphanumeric with hyphens)", cmd.SlashCmd) } - // OpenCode command format: YAML frontmatter with description only - // The content after frontmatter is the prompt that gets executed + // OpenCode command format: YAML frontmatter + embedded content // See: https://opencode.ai/docs/commands/ - content := fmt.Sprintf(`--- -description: %s ---- -!taskwing slash %s -`, cmd.Description, cmd.SlashCmd) + body, err := skills.GetBody(cmd.SlashCmd) + if err != nil { + return fmt.Errorf("load skill content for %s: %w", cmd.SlashCmd, err) + } + content := fmt.Sprintf("---\ndescription: %s\n---\n%s\n", cmd.Description, body) // Write .md file directly in commands directory filePath := filepath.Join(commandsDir, cmd.SlashCmd+".md") @@ -1082,36 +1165,34 @@ const ( ) // taskwingDocSectionHeader is the static top portion of the documentation block. +// The behavioral instructions at the top tell AI tools WHEN to use TaskWing MCP, +// not just what's available. This is what makes the AI proactively use the tools. const taskwingDocSectionHeader = ` ## TaskWing Integration -TaskWing extracts architectural knowledge from your codebase and stores it locally, giving every AI tool instant context via MCP. - -### Supported Models +This project uses TaskWing for architectural knowledge management. You have access to TaskWing MCP tools. - -[![OpenAI](https://img.shields.io/badge/OpenAI-412991?logo=openai&logoColor=white)](https://platform.openai.com/) -[![Anthropic](https://img.shields.io/badge/Anthropic-191919?logo=anthropic&logoColor=white)](https://www.anthropic.com/) -[![Google Gemini](https://img.shields.io/badge/Google_Gemini-4285F4?logo=google&logoColor=white)](https://ai.google.dev/) -[![AWS Bedrock](https://img.shields.io/badge/AWS_Bedrock-OpenAI--Compatible_Beta-FF9900?logo=amazonaws&logoColor=white)](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-chat-completions.html) -[![Ollama](https://img.shields.io/badge/Ollama-Local-000000?logo=ollama&logoColor=white)](https://ollama.com/) - +### TaskWing Workflow Contract v1 +1. No implementation before a clarified and approved plan/task checkpoint. +2. No completion claim without fresh verification evidence. +3. No debug fix proposal without root-cause evidence. -### Works With +### MCP Tools (use directly, no skill needed) +- ` + "`ask`" + ` -- Search project knowledge before modifying unfamiliar code. +- ` + "`remember`" + ` -- Persist a decision or pattern for future sessions. +- ` + "`code`" + ` -- Find symbols, explain call graphs, analyze impact, simplify code. +- ` + "`debug`" + ` -- Diagnose issues with root-cause analysis. +- ` + "`task`" + ` with action=current -- Check current task status. - -[![Claude Code](https://img.shields.io/badge/Claude_Code-191919?logo=anthropic&logoColor=white)](https://www.anthropic.com/claude-code) -[![OpenAI Codex](https://img.shields.io/badge/OpenAI_Codex-412991?logo=openai&logoColor=white)](https://developers.openai.com/codex) -[![Cursor](https://img.shields.io/badge/Cursor-111111?logo=cursor&logoColor=white)](https://cursor.com/) -[![GitHub Copilot](https://img.shields.io/badge/GitHub_Copilot-181717?logo=githubcopilot&logoColor=white)](https://github.com/features/copilot) -[![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-4285F4?logo=google&logoColor=white)](https://github.com/google-gemini/gemini-cli) -[![OpenCode](https://img.shields.io/badge/OpenCode-000000?logo=opencode&logoColor=white)](https://opencode.ai/) - +**When to use TaskWing MCP tools:** +- Before modifying unfamiliar code: call ` + "`ask`" + ` to check for relevant decisions, constraints, and patterns +- Before planning multi-step work: call ` + "`plan`" + ` with action=clarify to get a structured plan +- When asked about architecture, tech stack, or "why" questions: call ` + "`ask`" + ` with answer=true +- After making an architectural decision: call ` + "`remember`" + ` to persist it for future sessions +- To understand a symbol's role and callers: call ` + "`code`" + ` with action=explain - -Brand names and logos are trademarks of their respective owners; usage here indicates compatibility, not endorsement. - +**Do not** grep or read files to answer architecture questions when TaskWing MCP is available. The knowledge graph has pre-extracted, verified decisions with evidence. ` diff --git a/internal/bootstrap/integration_health.go b/internal/bootstrap/integration_health.go index f3f8d45..bd8c4a0 100644 --- a/internal/bootstrap/integration_health.go +++ b/internal/bootstrap/integration_health.go @@ -416,7 +416,7 @@ func evalCommandsComponent(basePath, aiName string, cfg aiHelperConfig) (Compone if readErr != nil { continue } - if strings.Contains(strings.ToLower(string(b)), "taskwing slash") || strings.Contains(strings.ToLower(string(b)), "!taskwing") { + if strings.Contains(strings.ToLower(string(b)), "taskwing") { taskwingLike = true break } diff --git a/internal/bootstrap/mcp_healthcheck_test.go b/internal/bootstrap/mcp_healthcheck_test.go index 74e42b1..9a71884 100644 --- a/internal/bootstrap/mcp_healthcheck_test.go +++ b/internal/bootstrap/mcp_healthcheck_test.go @@ -266,7 +266,7 @@ func TestClaudeDriftDetection(t *testing.T) { t.Fatal(err) } - // Write expected slash command files in the taskwing/ subdirectory + // Write expected command files in the taskwing/ subdirectory for name := range expectedSlashCommandFiles(".md") { if err := os.WriteFile(filepath.Join(nsDir, name), []byte("test"), 0o644); err != nil { t.Fatal(err) diff --git a/internal/bootstrap/planner.go b/internal/bootstrap/planner.go index 600e8ac..c4f72de 100644 --- a/internal/bootstrap/planner.go +++ b/internal/bootstrap/planner.go @@ -118,7 +118,7 @@ type Flags struct { TraceFile string `json:"trace_file,omitempty"` Verbose bool `json:"verbose"` Quiet bool `json:"quiet"` - Debug bool `json:"debug"` // Enable debug logging (dumps project context, git paths, etc.) + Debug bool `json:"debug"` // Enable debug logging (dumps project context, git paths, etc.) } // Plan captures the decisions about what to do. @@ -146,9 +146,9 @@ type Plan struct { SelectedAIs []string `json:"selected_ais,omitempty"` // User's actual AI selection // Multi-repo workspace selection - DetectedRepos []string `json:"detected_repos,omitempty"` - SelectedRepos []string `json:"selected_repos,omitempty"` - RequiresRepoSelection bool `json:"requires_repo_selection"` + DetectedRepos []string `json:"detected_repos,omitempty"` + SelectedRepos []string `json:"selected_repos,omitempty"` + RequiresRepoSelection bool `json:"requires_repo_selection"` // Error state Error error `json:"-"` @@ -296,7 +296,16 @@ func DecidePlan(snap *Snapshot, flags Flags) *Plan { // Project OK but some AI configs need repair plan.Mode = ModeRepair aisToRepair := getAIsNeedingRepair(snap) - plan.DetectedState = fmt.Sprintf("AI configurations need repair: %s", strings.Join(aisToRepair, ", ")) + // Include the reason for each AI needing repair + var repairDetails []string + for _, ai := range aisToRepair { + if health, ok := snap.AIHealth[ai]; ok && health.Reason != "" { + repairDetails = append(repairDetails, fmt.Sprintf("%s (%s)", ai, health.Reason)) + } else { + repairDetails = append(repairDetails, ai) + } + } + plan.DetectedState = fmt.Sprintf("AI configurations need repair: %s", strings.Join(repairDetails, ", ")) plan.AIsNeedingRepair = aisToRepair // Managed local drift is auto-repaired in bootstrap mode. plan.RequiresUserInput = false @@ -373,11 +382,9 @@ func DecidePlan(snap *Snapshot, flags Flags) *Plan { fmt.Sprintf("Unmanaged drift detected for: %s. TaskWing will not mutate these automatically.", strings.Join(plan.UnmanagedDriftAIs, ", "))) plan.Warnings = append(plan.Warnings, "Run: taskwing doctor --fix --adopt-unmanaged") } - if len(plan.GlobalMCPDriftAIs) > 0 { - plan.Warnings = append(plan.Warnings, - fmt.Sprintf("Global MCP drift detected for: %s. Bootstrap will not mutate global MCP in run mode.", strings.Join(plan.GlobalMCPDriftAIs, ", "))) - plan.Warnings = append(plan.Warnings, "Run: taskwing doctor --fix") - } + // Global MCP drift is not surfaced as a warning during bootstrap. + // Users who need global MCP can use 'tw doctor --fix' explicitly. + // Bootstrap should not nag about optional global configuration. // NoOp detection if len(plan.Actions) == 0 && plan.Mode != ModeError { @@ -779,78 +786,60 @@ func countSourceFiles(basePath string) int { func FormatPlanSummary(plan *Plan, quiet bool) string { var sb strings.Builder - // Always show single-line status - fmt.Fprintf(&sb, "Bootstrap: mode=%s", plan.Mode) + // Quiet mode: single-line machine-readable status + if quiet { + fmt.Fprintf(&sb, "Bootstrap: mode=%s", plan.Mode) + if len(plan.Actions) > 0 { + actionNames := make([]string, len(plan.Actions)) + for i, a := range plan.Actions { + actionNames[i] = string(a) + } + fmt.Fprintf(&sb, " actions=[%s]", strings.Join(actionNames, ",")) + } + sb.WriteString("\n") + return sb.String() + } + + // Human-readable summary + fmt.Fprintf(&sb, "%s\n", plan.DetectedState) - if len(plan.Actions) > 0 { - actionNames := make([]string, len(plan.Actions)) - for i, a := range plan.Actions { - actionNames[i] = string(a) + if plan.RequiresRepoSelection { + if len(plan.SelectedRepos) > 0 { + fmt.Fprintf(&sb, "Workspace: %d repositories selected\n", len(plan.SelectedRepos)) + } else if len(plan.DetectedRepos) > 0 { + fmt.Fprintf(&sb, "Workspace: %d repositories detected\n", len(plan.DetectedRepos)) } - fmt.Fprintf(&sb, " actions=[%s]", strings.Join(actionNames, ",")) } - if len(plan.Warnings) > 0 { - fmt.Fprintf(&sb, " warnings=%d", len(plan.Warnings)) + // Show what will happen + if len(plan.Actions) > 0 { + sb.WriteString("\n") + for _, summary := range plan.ActionSummary { + fmt.Fprintf(&sb, " %s\n", summary) + } } + + // Drift: show which tools are being updated (concise) if len(plan.ManagedDriftAIs) > 0 { - fmt.Fprintf(&sb, " managed_drift_fixed=%s", strings.Join(plan.ManagedDriftAIs, ",")) + fmt.Fprintf(&sb, "\n Updating: %s\n", strings.Join(plan.ManagedDriftAIs, ", ")) } if len(plan.UnmanagedDriftAIs) > 0 { - fmt.Fprintf(&sb, " unmanaged_drift_detected=%s", strings.Join(plan.UnmanagedDriftAIs, ",")) - } - if len(plan.GlobalMCPDriftAIs) > 0 { - fmt.Fprintf(&sb, " global_mcp_drift_detected=%s", strings.Join(plan.GlobalMCPDriftAIs, ",")) + fmt.Fprintf(&sb, "\n Detected unmanaged config: %s\n", strings.Join(plan.UnmanagedDriftAIs, ", ")) + sb.WriteString(" Run 'taskwing doctor --fix --adopt-unmanaged' to claim.\n") } + // Global MCP drift is not shown in bootstrap plan summary. + // Use 'tw doctor' for optional global MCP setup. - sb.WriteString("\n") - - // Detailed output (not in quiet mode) - if !quiet { - fmt.Fprintf(&sb, "\nDetected: %s\n", plan.DetectedState) - - if plan.RequiresRepoSelection && len(plan.DetectedRepos) > 0 { - fmt.Fprintf(&sb, "Workspace: Multi-repo (%d repositories detected)\n", len(plan.DetectedRepos)) - } - - if len(plan.Actions) > 0 { - sb.WriteString("\nActions:\n") - for _, summary := range plan.ActionSummary { - fmt.Fprintf(&sb, " • %s\n", summary) - } - } - if len(plan.ManagedDriftAIs) > 0 || len(plan.UnmanagedDriftAIs) > 0 || len(plan.GlobalMCPDriftAIs) > 0 { - sb.WriteString("\nDrift:\n") - if len(plan.ManagedDriftAIs) > 0 { - fmt.Fprintf(&sb, " • managed_drift_fixed: %s\n", strings.Join(plan.ManagedDriftAIs, ", ")) - } - if len(plan.UnmanagedDriftAIs) > 0 { - fmt.Fprintf(&sb, " • unmanaged_drift_detected: %s\n", strings.Join(plan.UnmanagedDriftAIs, ", ")) - } - if len(plan.GlobalMCPDriftAIs) > 0 { - fmt.Fprintf(&sb, " • global_mcp_drift_detected: %s\n", strings.Join(plan.GlobalMCPDriftAIs, ", ")) - } - } - - if len(plan.SkippedActions) > 0 { - sb.WriteString("\nSkipped:\n") - for _, skipped := range plan.SkippedActions { - fmt.Fprintf(&sb, " ⊘ %s\n", skipped) - } - } - - if len(plan.Warnings) > 0 { - sb.WriteString("\nWarnings:\n") - for _, warning := range plan.Warnings { - fmt.Fprintf(&sb, " ⚠️ %s\n", warning) - } + if len(plan.SkippedActions) > 0 { + sb.WriteString("\n Skipped:\n") + for _, skipped := range plan.SkippedActions { + fmt.Fprintf(&sb, " %s\n", skipped) } + } - if len(plan.Reasons) > 0 { - sb.WriteString("\nWhy:\n") - for _, reason := range plan.Reasons { - fmt.Fprintf(&sb, " → %s\n", reason) - } + if len(plan.Warnings) > 0 { + for _, warning := range plan.Warnings { + fmt.Fprintf(&sb, "\n Warning: %s\n", warning) } } diff --git a/internal/bootstrap/runner.go b/internal/bootstrap/runner.go index e91e028..e1a0155 100644 --- a/internal/bootstrap/runner.go +++ b/internal/bootstrap/runner.go @@ -3,7 +3,9 @@ package bootstrap import ( "context" "fmt" + "log/slog" "path/filepath" + "strings" "sync" "time" @@ -19,7 +21,7 @@ type Runner struct { // NewRunner creates a standard runner with default agents func NewRunner(cfg llm.Config, projectPath string) *Runner { return &Runner{ - agents: NewDefaultAgents(cfg, projectPath), + agents: NewDefaultAgents(cfg, projectPath, nil), } } @@ -40,17 +42,16 @@ func (r *Runner) Run(ctx context.Context, projectPath string) ([]core.Output, er return r.RunWithOptions(ctx, projectPath, RunOptions{Workspace: "root"}) } -// RunWithOptions executes all agents with the given options. -// Respects context cancellation - returns early if context is cancelled. +// RunWithOptions executes agents with the given options. +// For bootstrap mode, uses two-wave execution: doc+deps first, then code+git +// with context from wave 1. Watch mode uses single-wave parallel execution. func (r *Runner) RunWithOptions(ctx context.Context, projectPath string, opts RunOptions) ([]core.Output, error) { - // Check for early cancellation before starting any work select { case <-ctx.Done(): return nil, ctx.Err() default: } - // Default workspace to 'root' if not specified workspace := opts.Workspace if workspace == "" { workspace = "root" @@ -60,21 +61,107 @@ func (r *Runner) RunWithOptions(ctx context.Context, projectPath string, opts Ru BasePath: projectPath, ProjectName: filepath.Base(projectPath), Mode: core.ModeBootstrap, - Verbose: true, // or configurable + Verbose: false, // Only enable with --verbose or --debug flags Workspace: workspace, } + // Split agents into waves + wave1Agents, wave2Agents := splitAgentsByWave(r.agents) + + // If no wave2 agents, just run everything in parallel (single wave) + if len(wave2Agents) == 0 { + return runParallel(ctx, r.agents, input) + } + + // Wave 1: doc + deps (parallel) + wave1Results, wave1Err := runParallel(ctx, wave1Agents, input) + if wave1Err != nil { + slog.Debug("wave 1 agents returned errors (non-fatal)", "error", wave1Err) + } + + // Build context from wave 1 findings for wave 2 + wave1Context := buildWaveContext(wave1Results) + + // Wave 2: code + git (parallel, with wave 1 context) + wave2Input := input + if wave2Input.ExistingContext == nil { + wave2Input.ExistingContext = make(map[string]any) + } + for k, v := range wave1Context { + wave2Input.ExistingContext[k] = v + } + + wave2Results, err := runParallel(ctx, wave2Agents, wave2Input) + if err != nil { + // Return wave 1 results even if wave 2 fails + return append(wave1Results, wave2Results...), err + } + + return append(wave1Results, wave2Results...), nil +} + +// splitAgentsByWave separates agents into wave 1 (doc, deps) and wave 2 (code, git). +func splitAgentsByWave(agents []core.Agent) (wave1, wave2 []core.Agent) { + for _, a := range agents { + switch a.Name() { + case "doc", "deps": + wave1 = append(wave1, a) + default: + wave2 = append(wave2, a) + } + } + return wave1, wave2 +} + +// buildWaveContext converts wave 1 outputs into context for wave 2 agents. +// Truncates descriptions and total size to avoid blowing up the code agent's context budget. +func buildWaveContext(results []core.Output) map[string]any { + const maxDescLen = 200 // Truncate individual descriptions + const maxSummaryLen = 6000 // Cap total summary (~1.5k tokens) + + var summaryParts []string + totalLen := 0 + for _, r := range results { + if r.Error != nil || len(r.Findings) == 0 { + continue + } + for _, f := range r.Findings { + desc := f.Description + if len(desc) > maxDescLen { + desc = desc[:maxDescLen] + "..." + } + part := fmt.Sprintf("- [%s] %s: %s", f.Type, f.Title, desc) + totalLen += len(part) + 1 + if totalLen > maxSummaryLen { + summaryParts = append(summaryParts, "... (truncated)") + break + } + summaryParts = append(summaryParts, part) + } + if totalLen > maxSummaryLen { + break + } + } + if len(summaryParts) == 0 { + return nil + } + return map[string]any{ + "wave1_summary": strings.Join(summaryParts, "\n"), + } +} + +// runParallel executes agents concurrently and collects results. +func runParallel(ctx context.Context, agents []core.Agent, input core.Input) ([]core.Output, error) { var results []core.Output var wg sync.WaitGroup var mu sync.Mutex var errs []error - for _, agent := range r.agents { + for _, agent := range agents { wg.Add(1) go func(a core.Agent) { defer wg.Done() - // Check for cancellation before running agent select { case <-ctx.Done(): mu.Lock() @@ -84,7 +171,6 @@ func (r *Runner) RunWithOptions(ctx context.Context, projectPath string, opts Ru default: } - // Run agent start := time.Now() out, err := a.Run(ctx, input) duration := time.Since(start) @@ -93,12 +179,10 @@ func (r *Runner) RunWithOptions(ctx context.Context, projectPath string, opts Ru defer mu.Unlock() if err != nil { - // We log/collect error but don't stop other agents errs = append(errs, fmt.Errorf("agent %s failed: %w", a.Name(), err)) return } - // Ensure duration is set if agent didn't set it if out.Duration == 0 { out.Duration = duration } @@ -109,15 +193,13 @@ func (r *Runner) RunWithOptions(ctx context.Context, projectPath string, opts Ru wg.Wait() - // Check if context was cancelled during execution if ctx.Err() != nil { - return results, ctx.Err() // Return partial results with cancellation error + return results, ctx.Err() } if len(results) == 0 && len(errs) > 0 { return nil, fmt.Errorf("all agents failed: %v", errs) } - // Return raw results (caller aggregates) return results, nil } diff --git a/internal/bootstrap/service.go b/internal/bootstrap/service.go index a59ef9c..f1341cc 100644 --- a/internal/bootstrap/service.go +++ b/internal/bootstrap/service.go @@ -14,6 +14,7 @@ import ( "github.com/josephgoksu/TaskWing/internal/llm" "github.com/josephgoksu/TaskWing/internal/memory" "github.com/josephgoksu/TaskWing/internal/project" + "github.com/josephgoksu/TaskWing/internal/ui" ) // Service handles the bootstrapping process of extracting architectural knowledge. @@ -57,23 +58,45 @@ func (s *Service) RegenerateAIConfigs(verbose bool, targetAIs []string) error { return s.initializer.RegenerateConfigs(verbose, targetAIs) } +// ProgressFunc is called during multi-repo analysis with the service name and status. +type ProgressFunc func(serviceName string, status string) + // RunMultiRepoAnalysis executes analysis for all services in a workspace. // Each service's findings are tagged with the service name as workspace. -func (s *Service) RunMultiRepoAnalysis(ctx context.Context, ws *project.WorkspaceInfo) ([]core.Finding, []core.Relationship, []string, error) { +// If onProgress is non-nil, it is called before and after each service analysis. +// NOTE: Not safe for concurrent use. Swaps global project context per-service. +func (s *Service) RunMultiRepoAnalysis(ctx context.Context, ws *project.WorkspaceInfo, onProgress ProgressFunc) ([]core.Finding, []core.Relationship, []string, error) { var allFindings []core.Finding var allRelationships []core.Relationship var serviceErrors []string - for _, serviceName := range ws.Services { + // Save the workspace-level project context to restore after each service + workspaceCtx := config.GetProjectContext() + + for i, serviceName := range ws.Services { servicePath := ws.GetServicePath(serviceName) + + if onProgress != nil { + onProgress(serviceName, fmt.Sprintf("[%d/%d] analyzing...", i+1, len(ws.Services))) + } + + // Set per-service project context so git agents get the correct scopePath + if svcCtx, detectErr := project.Detect(servicePath); detectErr == nil { + _ = config.SetProjectContext(svcCtx) + } + runner := NewRunner(s.llmCfg, servicePath) // Pass workspace (service name) to the runner so agents can tag their findings results, err := runner.RunWithOptions(ctx, servicePath, RunOptions{Workspace: serviceName}) // Close runner immediately after use - NOT deferred in loop! - // Deferring in a loop keeps all resources open until function exit. runner.Close() + // Restore workspace context after each service + if workspaceCtx != nil { + _ = config.SetProjectContext(workspaceCtx) + } + if err != nil { serviceErrors = append(serviceErrors, fmt.Sprintf("%s: %s", serviceName, err.Error())) continue @@ -117,6 +140,10 @@ func (s *Service) RunMultiRepoAnalysis(ctx context.Context, ws *project.Workspac allFindings = append(allFindings, findings...) allRelationships = append(allRelationships, relationships...) + + if onProgress != nil { + onProgress(serviceName, fmt.Sprintf("[%d/%d] done (%d findings)", i+1, len(ws.Services), len(findings))) + } } return allFindings, allRelationships, serviceErrors, nil @@ -132,8 +159,8 @@ func (s *Service) ProcessAndSaveResults(ctx context.Context, results []core.Outp fmt.Fprintf(os.Stderr, "⚠️ Failed to save bootstrap report: %v\n", err) } - // 2. Print summary (could serve as return value if we want pure separation, but fine here for CLI svc) - printCoverageSummary(report) + // 2. Print summary using consistent UI renderer + ui.RenderBootstrapResults(report) if isPreview { fmt.Println("\n💡 This was a preview. Run 'taskwing bootstrap' to save to memory.") @@ -432,42 +459,3 @@ func saveReport(path string, report *core.BootstrapReport) error { } return os.WriteFile(path, data, 0644) } - -func printCoverageSummary(report *core.BootstrapReport) { - fmt.Println() - fmt.Println("📊 Bootstrap Coverage Report") - fmt.Println("────────────────────────────") - fmt.Printf(" Files analyzed: %d\n", report.Coverage.FilesAnalyzed) - fmt.Printf(" Files skipped: %d\n", report.Coverage.FilesSkipped) - fmt.Printf(" Coverage: %.1f%%\n", report.Coverage.CoveragePercent) - fmt.Printf(" Total findings: %d\n", report.TotalFindings) - - if len(report.FindingCounts) > 0 { - fmt.Println() - fmt.Println(" Findings by type:") - for fType, count := range report.FindingCounts { - fmt.Printf(" • %s: %d\n", fType, count) - } - } - - fmt.Println() - fmt.Println(" Per-agent coverage:") - for name, ar := range report.AgentReports { - status := "✓" - if ar.Error != "" { - status = "✗" - } - fileWord := "files" - if ar.Coverage.FilesAnalyzed == 1 { - fileWord = "file" - } - findingWord := "findings" - if ar.FindingCount == 1 { - findingWord = "finding" - } - fmt.Printf(" %s %s: %d %s, %d %s\n", status, name, ar.Coverage.FilesAnalyzed, fileWord, ar.FindingCount, findingWord) - } - - fmt.Println() - fmt.Printf("📄 Full report: .taskwing/last-bootstrap-report.json\n") -} diff --git a/internal/brief/brief.go b/internal/brief/brief.go index ea6f5d4..f66df86 100644 --- a/internal/brief/brief.go +++ b/internal/brief/brief.go @@ -16,7 +16,7 @@ import ( // No node IDs, file paths, or embeddings are included. // // This function is used by: -// - /taskwing:ask slash command (project knowledge brief) +// - ask MCP tool (project knowledge brief) // - SessionStart hook auto-injection func GenerateCompactBrief(repo *memory.Repository) (string, error) { nodes, err := repo.ListNodes("") @@ -70,7 +70,7 @@ func FormatNodesAsCompactBrief(nodes []memory.Node) string { continue } - fmt.Fprintf(&sb, "\n%s %ss\n", typeIcon(t), utils.ToTitle(t)) + fmt.Fprintf(&sb, "\n%s %s\n", typeIcon(t), utils.ToTitle(typePluralLabel(t))) for _, n := range groupNodes { summary := n.Summary @@ -84,6 +84,18 @@ func FormatNodesAsCompactBrief(nodes []memory.Node) string { return sb.String() } +// typePluralLabel returns the correct plural form for a node type. +func typePluralLabel(t string) string { + switch t { + case memory.NodeTypeMetadata: + return "metadata" + case memory.NodeTypeDocumentation: + return "docs" + default: + return t + "s" + } +} + // typeIcon returns the emoji icon for a node type. func typeIcon(t string) string { switch t { diff --git a/internal/codeintel/repository.go b/internal/codeintel/repository.go index b7daf33..8b05fa9 100644 --- a/internal/codeintel/repository.go +++ b/internal/codeintel/repository.go @@ -443,6 +443,9 @@ func (r *SQLiteRepository) GetImpactRadius(ctx context.Context, symbolID uint32, Relation: relation, }) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } return results, nil } @@ -519,6 +522,9 @@ func (r *SQLiteRepository) GetSymbolStats(ctx context.Context) (*SymbolStats, er } stats.ByLanguage[lang] = count } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } // Get kind breakdown rows, err = r.db.QueryContext(ctx, "SELECT kind, COUNT(*) FROM symbols GROUP BY kind ORDER BY COUNT(*) DESC") @@ -535,6 +541,9 @@ func (r *SQLiteRepository) GetSymbolStats(ctx context.Context) (*SymbolStats, er } stats.ByKind[kind] = count } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } return stats, nil } @@ -558,6 +567,9 @@ func (r *SQLiteRepository) GetStaleSymbolFiles(ctx context.Context, checkPath fu staleFiles = append(staleFiles, filePath) } } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } return staleFiles, nil } @@ -804,6 +816,9 @@ func scanSymbols(rows *sql.Rows) ([]Symbol, error) { symbols = append(symbols, s) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } return symbols, nil } @@ -834,6 +849,9 @@ func scanSymbolsWithEmbeddings(rows *sql.Rows) ([]Symbol, error) { symbols = append(symbols, s) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } return symbols, nil } @@ -965,6 +983,9 @@ func (r *SQLiteRepository) GetDependencies(ctx context.Context, ecosystem *strin deps = append(deps, d) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } return deps, nil } @@ -1016,6 +1037,9 @@ func (r *SQLiteRepository) SearchDependenciesFTS(ctx context.Context, query stri deps = append(deps, d) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } return deps, nil } diff --git a/internal/config/paths.go b/internal/config/paths.go index 2dbe08a..4ed51da 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -64,16 +64,6 @@ func GetProjectContext() *project.Context { return projectContext } -// GetProjectContextOrError returns the project context or an error if not set. -// Use this when project context is required. -func GetProjectContextOrError() (*project.Context, error) { - ctx := GetProjectContext() - if ctx == nil { - return nil, ErrProjectContextNotSet - } - return ctx, nil -} - // DetectAndSetProjectContext detects the project root and sets it. // Returns error if detection fails - no silent fallbacks. func DetectAndSetProjectContext() (*project.Context, error) { diff --git a/internal/config/prompts.go b/internal/config/prompts.go index c384ee4..8665bc2 100644 --- a/internal/config/prompts.go +++ b/internal/config/prompts.go @@ -100,7 +100,7 @@ When you have gathered sufficient information, respond with a JSON analysis: // Use with Eino ChatTemplate (Go Template format). const PromptTemplateDocAgent = `You are a technical analyst. Analyze the following documentation for project "{{.ProjectName}}". -Extract THREE types of information with VERIFIABLE EVIDENCE: +Extract FOUR types of information with VERIFIABLE EVIDENCE: ## 1. PRODUCT FEATURES Things the product does for users (not technical implementation). @@ -109,7 +109,21 @@ Things the product does for users (not technical implementation). - Confidence: 0.0-1.0 (how clearly is this documented?) - Evidence: exact quote with file and line numbers -## 2. ARCHITECTURAL CONSTRAINTS +## 2. TECHNOLOGY DECISIONS +Explicit choices of libraries, frameworks, tools, or architectural approaches. Look for: +- Tech stack tables ("Library | Why") +- Comparison tables ("Approach | Accuracy | Why we chose X") +- "Why X over Y" sections +- Statements like "We use X because..." or "Chosen for..." +- Architecture diagrams with named technologies + +For each decision: +- Title: "[Category] - [Technology]" (e.g., "HTTP Framework - Echo v4") +- Summary: what was chosen and why (include the rationale) +- Alternatives: what was considered but rejected (if documented) +- Confidence: 0.0-1.0 + +## 3. ARCHITECTURAL CONSTRAINTS Mandatory rules developers MUST follow. Look for: - Words like: CRITICAL, MUST, REQUIRED, mandatory, always, never - Database access rules (replicas, connection pools) @@ -117,7 +131,7 @@ Mandatory rules developers MUST follow. Look for: - Security requirements - Performance mandates -## 3. DEVELOPMENT & CI/CD WORKFLOWS +## 4. DEVELOPMENT & CI/CD WORKFLOWS Explicit commands, scripts, or CI/CD pipeline steps. Look for: - "To do X, run Y" - "When changing A, you must also update B" @@ -151,6 +165,22 @@ RESPOND IN JSON: ] } ], + "decisions": [ + { + "title": "HTTP Framework - Echo v4", + "summary": "Chosen for batteries-included CORS, JWT, logging, file upload; returns errors instead of panicking", + "alternatives": "Gin, Chi, stdlib net/http", + "confidence": 0.95, + "evidence": [ + { + "file_path": "ARCHITECTURE.md", + "start_line": 8, + "end_line": 12, + "snippet": "| HTTP Framework | Echo v4 | Batteries-included (CORS, JWT, logging)..." + } + ] + } + ], "constraints": [ { "rule": "Use ReadReplica for high-volume reads", @@ -185,8 +215,8 @@ RESPOND IN JSON: ], "relationships": [ { - "from": "Feature or Constraint name", - "to": "Related Feature or Constraint name", + "from": "Feature or Decision or Constraint name", + "to": "Related Feature or Decision or Constraint name", "relation": "depends_on|affects|extends", "reason": "Why they are related" } @@ -466,32 +496,21 @@ const SystemPromptClarifyingAgent = `You are a Senior Technical Architect helpin Your job is to ask clarifying questions to turn a vague request into a concrete specification. **Guidelines:** -1. **Reason First**: Analyze the goal, technologies, and project context. -2. **Create Goal Summary**: Generate a concise one-line summary (max 80 chars) that captures the essence of the goal. This appears in UI lists. -3. **Draft the Specification**: Even if you have questions, ALWAYS provide your best effort "enriched_goal" as a technical specification. -4. **Ask ONLY Essential Questions**: Maximum 3 questions. See Question Rules below. +1. **Reason First**: Analyze the goal and the provided Architectural Knowledge context. +2. **Create Goal Summary**: Generate a concise one-line summary (max 80 chars) that captures the essence of the goal. +3. **Draft the Specification**: Even if you have questions, ALWAYS provide your best effort "enriched_goal" as a detailed technical specification. +4. **Ask ONLY Essential Questions**: Maximum 3 questions. See rules below. 5. **Detect Completion**: If the goal is clear enough to start coding, set "is_ready_to_plan" to true. -6. **Professionalism**: The "enriched_goal" MUST be a detailed technical specification, not just a summary. - -**CRITICAL - Question Rules:** -You have access to Architectural Knowledge from the codebase. Use it. DO NOT ask questions you can answer yourself. -✅ ONLY ask questions the USER uniquely knows: -- **Preferences**: Visual style, UX priorities, naming conventions they prefer -- **Scope decisions**: What to include/exclude, MVP vs full feature -- **Business constraints**: Deadlines, team size, performance requirements -- **Prioritization**: Which aspects matter most to them +**CRITICAL - The Architectural Knowledge context is your source of truth.** +Everything in it (tech stack, patterns, constraints, decisions) is a FACT. Use it directly in your specification. +Do NOT ask questions about anything already stated in the context. +Only ask questions about things the context does NOT cover and that only the user can decide (scope, priority, preferences). -❌ NEVER ask questions you can answer from context: -- Tech stack (visible in package.json, go.mod, dependencies) -- Design system (visible in CSS, Tailwind config, component library) -- API endpoints (visible in routes, handlers, OpenAPI specs) -- Database schema (visible in models, migrations) -- Existing patterns (visible in code structure, similar features) -- Authentication/authorization (visible in middleware, guards) - -If you're tempted to ask "Do you have X?" or "What is your Y?" - CHECK THE CONTEXT FIRST. -If the answer is in the context, state what you found and ask if they want to change it. +**Question Format:** +Every question MUST include concrete options so the user can pick, modify, or extend. +Format: "[Topic]: [Option A] vs [Option B]. [Brief tradeoff]." +This lets the user reply "first one" or "B but also add X" instead of writing paragraphs. **Input Context:** Goal: {{.Goal}} @@ -504,9 +523,9 @@ Architectural Knowledge: **Output Format (JSON):** { - "questions": ["Question 1", "Question 2"], // ONLY questions user uniquely knows - "goal_summary": "Concise one-line summary for UI display (max 80 chars)", // e.g. "Add OAuth2 authentication with Google SSO" - "enriched_goal": "A detailed technical specification including tech stack, patterns, and scope...", // ALWAYS provide this + "questions": ["Topic: Option A vs Option B. Tradeoff note."], + "goal_summary": "Concise one-line summary for UI display (max 80 chars)", + "enriched_goal": "A detailed technical specification using facts from context...", "is_ready_to_plan": boolean // true if sufficient info gathered } ` @@ -516,16 +535,17 @@ const SystemPromptPlanningAgent = `You are an Engineering Lead creating a develo Your input is an "Enriched Goal" and relevant context from the project knowledge graph. Your job is to decompose this goal into a sequential list of actionable execution tasks. +**CRITICAL - Task Count:** +Use the minimum number of tasks needed. Do NOT over-decompose. +- If it can be done in 1 task, use 1 task. +- Simple changes: 1 task. Small features: 1-2. Medium features: 2-4. Large features: 4-6. System-wide: 5-8. + **Guidelines:** -1. **Atomic Tasks**: Each task must be a clear unit of work (e.g., "Create database schema", "Implement auth middleware"). +1. **Self-Contained Tasks**: Each task MUST include enough context to be executed independently by any AI coding agent without seeing the full plan. Reference relevant decisions, constraints, and patterns from the Knowledge Graph. 2. **Dependencies**: Respect logical order. A task cannot rely on something not yet built. -3. **Context Aware**: Use the provided Knowledge Graph Context. Link tasks to existing Features/Patterns if mentioned. -4. **CRITICAL - Constraint Compliance**: If the context contains architectural CONSTRAINTS or RULES (marked as CRITICAL, MUST, mandatory, or with severity: critical/high), you MUST ensure ALL tasks comply with them. For example: - - If a ReadReplica constraint exists, database queries MUST use the replica - - If a caching constraint exists, high-volume endpoints MUST implement caching - - Never suggest code that violates documented constraints -5. **Verification**: For each task, define clear acceptance criteria and a validation command (e.g., "go test ./..."). -6. **No Overlap**: Each task must be a distinct, non-overlapping unit of work. Do NOT create separate tasks for testing and implementation of the same feature — combine them. If multiple tasks would modify the same files or address the same problem from different angles, merge them into one task. When a caller provides an explicit tasks array, use those tasks directly instead of generating new ones. +3. **Constraint Compliance**: Tasks MUST comply with all constraints from the Knowledge Graph. +4. **Verification**: Each task needs acceptance criteria and a validation command. +5. **No Overlap**: Do NOT split implementation and testing of the same feature into separate tasks. When explicit tasks are provided, use them directly. **Input Context:** - Enriched Goal: {{.Goal}} @@ -536,79 +556,60 @@ Your job is to decompose this goal into a sequential list of actionable executio "tasks": [ { "title": "Task Title", - "description": "DETAILED step-by-step instructions (Must NOT be empty). MUST reference relevant constraints.", + "description": "Detailed instructions with file paths, patterns, constraints, and context. Self-contained for independent execution.", "acceptance_criteria": ["Criteria 1", "Criteria 2"], - "validation_steps": ["go test ./..."], - "priority": 80, // 0-100 - "assigned_agent": "coder", // or "doc", "architect" - "dependencies": ["Title of Task A"], // List of task titles that must be completed BEFORE this task - "complexity": "medium" // "low", "medium", or "high" + "validation_steps": ["validation command"], + "priority": 80, + "assigned_agent": "coder", + "dependencies": ["Title of dependency task"], + "complexity": "medium" } ], - "rationale": "Why you chose this approach and how it adheres to architectural constraints..." + "rationale": "Why this approach and how it respects architectural constraints..." } ` // SystemPromptDecompositionAgent is the system prompt for the Decomposition Agent. // Breaks enriched goals into 3-5 high-level phases for interactive planning. const SystemPromptDecompositionAgent = `You are an Engineering Lead decomposing a development goal into high-level phases. -Your input is an "Enriched Goal" (technical specification) and relevant context from the project knowledge graph. -Your job is to break this down into 3-5 logical phases that can be reviewed and refined independently. +Break the goal into 3-5 logical phases that deliver incremental value. **Guidelines:** -1. **Phase Scope**: Each phase should be a coherent work chunk that delivers incremental value. -2. **Sequential Dependencies**: Earlier phases should enable later ones. Design for incremental delivery. -3. **Right-Sized**: Each phase should expand into 2-4 detailed tasks (not too granular, not too vague). -4. **Clear Done State**: Each phase should have a clear "done" condition that can be verified. -5. **Context Aware**: Use the provided Knowledge Graph Context to align with existing patterns and constraints. -6. **No Overlap**: Phases must not overlap in scope. Each phase should own a distinct set of files/concerns. Do not split related work (e.g., implementation + testing of the same feature) across phases. +1. Each phase is a coherent work chunk with a clear "done" condition. +2. Earlier phases enable later ones. Design for incremental delivery. +3. Each phase should expand into 2-4 tasks. No overlap between phases. +4. Use the Knowledge Graph Context to align with existing patterns and constraints. **Input Context:** - Enriched Goal: {{.EnrichedGoal}} - Knowledge Graph: {{.Context}} -**Phase Design Principles:** -- Phase 1 is typically "Foundation" - setup, data models, core interfaces -- Middle phases are "Implementation" - main feature work -- Last phase is often "Integration" or "Polish" - connecting pieces, tests, documentation - **Output Format (JSON):** { "phases": [ { - "title": "Phase Title (action-oriented, e.g., 'Set up authentication infrastructure')", + "title": "Action-oriented phase title", "description": "What this phase accomplishes and its scope boundaries", "rationale": "Why this phase exists and what value it delivers", "expected_tasks": 3, "dependencies": [] - }, - { - "title": "Phase 2 Title", - "description": "...", - "rationale": "...", - "expected_tasks": 4, - "dependencies": ["Phase 1 Title"] } ], "rationale": "Overall reasoning for this phase breakdown and sequencing..." } - -Keep phases high-level but specific enough that a developer understands the scope. ` // SystemPromptExpandAgent is the system prompt for the Expand Agent. // Generates detailed tasks for a single phase during interactive planning. const SystemPromptExpandAgent = `You are an Engineering Lead expanding a development phase into detailed tasks. -Your input is a Phase (title, description) from a larger plan, along with the original goal and project context. -Your job is to generate 2-4 atomic tasks that fully accomplish this phase. +Generate 2-4 self-contained tasks that fully accomplish this phase. **Guidelines:** -1. **Atomic Tasks**: Each task must be a clear, single unit of work completable in one session. -2. **Sequential Order**: Tasks should be ordered by dependency - earlier tasks enable later ones. -3. **Complete Coverage**: The tasks together must fully accomplish the phase's stated goal. -4. **Context Aware**: Use the Knowledge Graph Context to respect existing patterns and constraints. -5. **Verifiable**: Each task must have clear acceptance criteria and validation steps. -6. **No Overlap**: Tasks must not duplicate effort. Do NOT create separate tasks for "write tests" and "implement feature" for the same change — combine them into one task. If two tasks would modify the same files, merge them. Check against tasks already generated for other phases to avoid cross-phase overlap. +1. Each task is a single unit of work completable in one session. +2. Each task MUST be self-contained with enough context for independent execution. +3. Tasks ordered by dependency. No overlap -- do not split implementation and testing. +4. Use the Knowledge Graph Context to respect existing patterns and constraints. +5. Each task needs acceptance criteria and validation steps. **Input Context:** - Phase Title: {{.PhaseTitle}} @@ -728,6 +729,219 @@ Architectural Context: } ` +// SystemPromptDocReactAgent is the system prompt for the ReAct documentation analysis agent. +// It explores documentation files dynamically using tools instead of a single pre-gathered context. +const SystemPromptDocReactAgent = `You are an expert technical analyst exploring a project's documentation to extract architectural knowledge. + +## Your Mission +Discover features, technology decisions, constraints, and workflows by reading documentation files. + +## Available Tools +- **list_dir**: Explore directory structure to find documentation +- **read_file**: Read file contents WITH LINE NUMBERS for evidence gathering +- **grep_search**: Search for patterns across the codebase +- **exec_command**: ONLY for git commands. Do NOT use for reading files. + +## Exploration Strategy +1. List root directory to find README.md, docs/, ARCHITECTURE.md, ADRs +2. Read README.md first for project overview +3. Search for decision-related docs: grep for "why", "chose", "alternative", "decision" +4. Read any docs/ or architecture/ directories +5. Look for CI/CD configs (.github/workflows/, Makefile) +6. Search for constraint keywords: "MUST", "CRITICAL", "REQUIRED", "NEVER" +7. When you have enough context, provide your analysis + +## CRITICAL: Evidence Requirements +Every finding MUST include structured evidence with: +- file_path: The relative path to the source file +- start_line: Starting line number (1-indexed) +- end_line: Ending line number (1-indexed) +- snippet: The actual text you observed + +Confidence scores (0.0-1.0): +- 0.9-1.0: Direct evidence (exact text match found) +- 0.7-0.89: Strong inference (clearly stated) +- 0.5-0.69: Reasonable inference (implied) +- Below 0.5: Weak inference (avoid these) + +## Output Format +` + "```json" + ` +{ + "features": [ + { + "name": "Feature Name", + "description": "What it does for users", + "confidence": 0.85, + "evidence": [{"file_path": "README.md", "start_line": 15, "end_line": 20, "snippet": "..."}] + } + ], + "decisions": [ + { + "title": "Decision Title", + "summary": "What was chosen and why", + "alternatives": "What was rejected", + "confidence": 0.9, + "evidence": [{"file_path": "ARCHITECTURE.md", "start_line": 8, "end_line": 12, "snippet": "..."}] + } + ], + "constraints": [ + { + "rule": "The constraint rule", + "reason": "Why it exists", + "severity": "critical", + "confidence": 0.95, + "evidence": [{"file_path": "CONTRIBUTING.md", "start_line": 45, "end_line": 50, "snippet": "..."}] + } + ], + "workflows": [ + { + "name": "Workflow Name", + "steps": "Step-by-step description", + "trigger": "When this applies", + "confidence": 0.9, + "evidence": [{"file_path": ".github/workflows/ci.yml", "start_line": 1, "end_line": 10, "snippet": "..."}] + } + ], + "relationships": [ + {"from": "Name A", "to": "Name B", "relation": "depends_on", "reason": "Why related"} + ] +} +` + "```" + ` + +## Rules +- Call tools to gather information before making conclusions +- Don't guess - use tools to verify assumptions +- Every finding MUST have at least one evidence item with file_path, line numbers, and snippet +- Confidence must be a NUMBER between 0.0 and 1.0 +- Stop when you have 5-15 solid findings with evidence` + +// SystemPromptDepsReactAgent is the system prompt for the ReAct dependency analysis agent. +// It explores dependency manifests and traces how dependencies are actually used. +const SystemPromptDepsReactAgent = `You are an expert technology analyst exploring a project's dependencies to understand technology decisions. + +## Your Mission +Identify key technology decisions by finding dependency manifests, reading them, and tracing how key dependencies are used in the codebase. + +## Available Tools +- **list_dir**: Explore directory structure to find dependency files +- **read_file**: Read file contents WITH LINE NUMBERS for evidence gathering +- **grep_search**: Search for import statements and usage patterns +- **exec_command**: ONLY for git commands. Do NOT use for reading files. + +## Exploration Strategy +1. List root directory to find package.json, go.mod, Cargo.toml, requirements.txt, pom.xml +2. Read each dependency manifest found +3. For key dependencies (frameworks, databases, auth libs), grep for import/usage +4. Read usage sites to understand WHY each key dep is used (not just THAT it exists) +5. Categorize decisions by layer (CLI, Storage, UI, API, Testing, etc.) +6. When you have enough context, provide your analysis + +## CRITICAL: Evidence Requirements +Every finding MUST include structured evidence with: +- file_path: The relative path to the source file +- start_line: Starting line number (1-indexed) +- end_line: Ending line number (1-indexed) +- snippet: The actual dependency declaration or usage code + +Confidence scores (0.0-1.0): +- 0.9-1.0: Direct evidence (dependency declared + usage found) +- 0.7-0.89: Strong inference (dependency declared, usage clear from name) +- 0.5-0.69: Reasonable inference (transitive dependency or unclear usage) + +## Output Format +` + "```json" + ` +{ + "tech_decisions": [ + { + "title": "Technology decision title", + "category": "Which layer (CLI Layer, Storage Layer, UI Layer, API Layer, Testing, etc.)", + "what": "What technology/framework/library", + "why": "Why this choice matters or was likely made (inferred from usage)", + "confidence": 0.9, + "evidence": [ + {"file_path": "go.mod", "start_line": 5, "end_line": 5, "snippet": "github.com/lib/pq v1.10.0"} + ] + } + ] +} +` + "```" + ` + +## Rules +- Call tools to gather information before making conclusions +- Don't just list dependencies - explain WHY each matters +- Trace key deps to usage sites for stronger evidence +- Every finding MUST have evidence with file_path, line numbers, and snippet +- Confidence must be a NUMBER between 0.0 and 1.0 +- Stop when you have 5-15 solid findings with evidence` + +// SystemPromptGitReactAgent is the system prompt for the ReAct git history analysis agent. +// It explores git history dynamically to find significant milestones. +const SystemPromptGitReactAgent = `You are an expert software historian exploring a project's git history to identify significant milestones and decisions. + +## Your Mission +Discover major features, architecture changes, technology decisions, and evolution patterns from git history. + +## Available Tools +- **list_dir**: Explore directory structure for context +- **read_file**: Read file contents for understanding changes +- **grep_search**: Search for patterns in code +- **exec_command**: Use for git commands. Examples: + - {"command": "git", "args": ["log", "--oneline", "-100"]} + - {"command": "git", "args": ["show", "--stat", "abc1234"]} + - {"command": "git", "args": ["log", "--oneline", "--grep=feat"]} + - {"command": "git", "args": ["shortlog", "-sn", "--all", "-5"]} + +## Exploration Strategy +1. Run git log --oneline -100 to get recent history overview +2. Identify significant commits: migrations, framework changes, major features +3. Run git show --stat on interesting commits to see what files changed +4. Look for patterns: conventional commits, release tags, refactoring waves +5. Optionally grep for architectural keywords in commit messages +6. When you have enough context, provide your analysis + +## CRITICAL: Evidence Requirements +Every finding MUST include structured evidence with: +- file_path: ".git/logs/HEAD" (for git-sourced findings) +- start_line: 0 +- end_line: 0 +- snippet: The commit hash and message that supports the finding +- grep_pattern: (optional) pattern to verify + +Confidence scores (0.0-1.0): +- 0.9-1.0: Direct evidence (explicit commit message + stat match) +- 0.7-0.89: Strong inference (clear commit pattern) +- 0.5-0.69: Reasonable inference (implied from history) + +## Output Format +` + "```json" + ` +{ + "milestones": [ + { + "title": "Clear, specific title", + "scope": "Component/feature name (e.g., 'auth', 'api', 'ui')", + "description": "What happened, why it matters", + "confidence": 0.8, + "evidence": [ + { + "file_path": ".git/logs/HEAD", + "start_line": 0, + "end_line": 0, + "snippet": "abc1234 2024-01-15 feat(auth): Add JWT authentication", + "grep_pattern": "feat(auth)" + } + ] + } + ] +} +` + "```" + ` + +## Rules +- Call tools to gather information before making conclusions +- Focus on DECISIONS and MILESTONES, not individual bug fixes +- Every finding MUST have evidence with commit hash and message +- Confidence must be a NUMBER between 0.0 and 1.0 +- Stop when you have 5-10 solid findings with evidence` + // SystemPromptDebugAgent is the system prompt for the Debug Agent. // Helps developers diagnose issues systematically. const SystemPromptDebugAgent = `You are a Senior Debugger helping diagnose software issues. diff --git a/internal/freshness/freshness.go b/internal/freshness/freshness.go index a5b42e3..74411f5 100644 --- a/internal/freshness/freshness.go +++ b/internal/freshness/freshness.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -83,7 +84,7 @@ func Check(basePath string, evidenceJSON string, referenceTime time.Time) Result fullPath = filepath.Join(basePath, p) } - info, err := statCached(fullPath) + info, err := defaultCache.stat(fullPath) if err != nil { if os.IsNotExist(err) { missing = append(missing, p) @@ -99,20 +100,12 @@ func Check(basePath string, evidenceJSON string, referenceTime time.Time) Result now := time.Now() - // All evidence files deleted - if len(missing) == len(paths) { - return Result{ - Status: StatusMissing, - MissingFiles: missing, - DecayFactor: 0.2, - CheckedAt: now, - } - } - - // Some files missing + // Decay formula: smooth curve from 1.0 (no missing) to 0.2 (all missing). + // decay = 1.0 - (missingRatio * 0.8) + // At 0% missing: 1.0, at 50%: 0.6, at 100%: 0.2 if len(missing) > 0 { missingRatio := float64(len(missing)) / float64(len(paths)) - decay := 1.0 - (missingRatio * 0.6) // 0.4 at 100% missing + decay := 1.0 - (missingRatio * 0.8) return Result{ Status: StatusMissing, StaleFiles: stale, @@ -188,24 +181,31 @@ func formatDuration(d time.Duration) string { } } -// --- Stat cache (avoids re-statting the same file within a session) --- +// --- Stat cache with bounded size and TTL eviction --- -var ( - cache = make(map[string]cacheEntry) - cacheMu sync.RWMutex - cacheTTL = 60 * time.Second +const ( + cacheTTL = 60 * time.Second + cacheMaxSize = 1000 // Evict oldest entries when exceeded ) +// statCache holds cached os.Stat results with TTL and bounded size. +type statCache struct { + mu sync.RWMutex + entries map[string]cacheEntry +} + type cacheEntry struct { info os.FileInfo err error checkedAt time.Time } -func statCached(path string) (os.FileInfo, error) { - cacheMu.RLock() - entry, ok := cache[path] - cacheMu.RUnlock() +var defaultCache = &statCache{entries: make(map[string]cacheEntry)} + +func (c *statCache) stat(path string) (os.FileInfo, error) { + c.mu.RLock() + entry, ok := c.entries[path] + c.mu.RUnlock() if ok && time.Since(entry.checkedAt) < cacheTTL { return entry.info, entry.err @@ -213,16 +213,62 @@ func statCached(path string) (os.FileInfo, error) { info, err := os.Stat(path) - cacheMu.Lock() - cache[path] = cacheEntry{info: info, err: err, checkedAt: time.Now()} - cacheMu.Unlock() + c.mu.Lock() + // Re-check: another goroutine may have refreshed this entry while we were statting + if existing, ok := c.entries[path]; ok && time.Since(existing.checkedAt) < cacheTTL { + c.mu.Unlock() + return existing.info, existing.err + } + c.entries[path] = cacheEntry{info: info, err: err, checkedAt: time.Now()} + // Evict when cache grows too large + if len(c.entries) > cacheMaxSize { + c.evictExpired() + // Fallback: if still over limit (burst scenario), evict oldest entries + if len(c.entries) > cacheMaxSize { + c.evictOldest(len(c.entries) - cacheMaxSize) + } + } + c.mu.Unlock() return info, err } +// evictExpired removes entries older than 2x TTL. Caller must hold write lock. +func (c *statCache) evictExpired() { + cutoff := time.Now().Add(-2 * cacheTTL) + for k, v := range c.entries { + if v.checkedAt.Before(cutoff) { + delete(c.entries, k) + } + } +} + +// evictOldest removes the n oldest entries. Caller must hold write lock. +func (c *statCache) evictOldest(n int) { + if n <= 0 { + return + } + type aged struct { + key string + at time.Time + } + items := make([]aged, 0, len(c.entries)) + for k, v := range c.entries { + items = append(items, aged{key: k, at: v.checkedAt}) + } + sort.Slice(items, func(i, j int) bool { return items[i].at.Before(items[j].at) }) + for i := 0; i < n && i < len(items); i++ { + delete(c.entries, items[i].key) + } +} + +func (c *statCache) reset() { + c.mu.Lock() + c.entries = make(map[string]cacheEntry) + c.mu.Unlock() +} + // ResetCache clears the stat cache. Used in tests and after bootstrap. func ResetCache() { - cacheMu.Lock() - cache = make(map[string]cacheEntry) - cacheMu.Unlock() + defaultCache.reset() } diff --git a/internal/freshness/freshness_test.go b/internal/freshness/freshness_test.go index 0d9d12a..23575ee 100644 --- a/internal/freshness/freshness_test.go +++ b/internal/freshness/freshness_test.go @@ -12,7 +12,6 @@ func TestCheckFresh(t *testing.T) { ResetCache() dir := t.TempDir() - // Create a file that's older than the reference time filePath := filepath.Join(dir, "main.go") if err := os.WriteFile(filePath, []byte("package main"), 0644); err != nil { t.Fatal(err) @@ -39,7 +38,6 @@ func TestCheckStale(t *testing.T) { t.Fatal(err) } - // Reference time is in the past (file is newer) result := Check(dir, mustJSON(t, []evidenceItem{{FilePath: "main.go"}}), time.Now().Add(-time.Hour)) if result.Status != StatusStale { @@ -53,7 +51,7 @@ func TestCheckStale(t *testing.T) { } } -func TestCheckMissing(t *testing.T) { +func TestCheckAllMissing(t *testing.T) { ResetCache() dir := t.TempDir() @@ -65,8 +63,9 @@ func TestCheckMissing(t *testing.T) { if len(result.MissingFiles) != 1 { t.Fatalf("expected 1 missing file, got %d", len(result.MissingFiles)) } - if result.DecayFactor != 0.2 { - t.Fatalf("expected decay 0.2 for all-missing, got %f", result.DecayFactor) + // All missing: decay = 1.0 - (1.0 * 0.8) = 0.2 + if result.DecayFactor < 0.19 || result.DecayFactor > 0.21 { + t.Fatalf("expected decay ~0.2 for all-missing, got %f", result.DecayFactor) } } @@ -95,7 +94,6 @@ func TestCheckSkipsBuildArtifacts(t *testing.T) { ResetCache() dir := t.TempDir() - // Create file in node_modules (should be skipped) nmDir := filepath.Join(dir, "node_modules") if err := os.MkdirAll(nmDir, 0755); err != nil { t.Fatal(err) @@ -107,7 +105,6 @@ func TestCheckSkipsBuildArtifacts(t *testing.T) { evidence := mustJSON(t, []evidenceItem{{FilePath: "node_modules/dep.js"}}) result := Check(dir, evidence, time.Now().Add(-time.Hour)) - // Should be no_evidence since the only evidence file was skipped if result.Status != StatusNoEvidence { t.Fatalf("expected no_evidence (build artifact skipped), got %s", result.Status) } @@ -117,7 +114,6 @@ func TestCheckMixedStaleAndFresh(t *testing.T) { ResetCache() dir := t.TempDir() - // Create two files if err := os.WriteFile(filepath.Join(dir, "fresh.go"), []byte("package a"), 0644); err != nil { t.Fatal(err) } @@ -125,11 +121,7 @@ func TestCheckMixedStaleAndFresh(t *testing.T) { t.Fatal(err) } - // Reference time: after fresh.go was written but before "now" - // Both files have same mtime (just created), so use a past reference to make both stale - // or a future reference to make both fresh refTime := time.Now().Add(-time.Hour) - evidence := mustJSON(t, []evidenceItem{ {FilePath: "fresh.go"}, {FilePath: "stale.go"}, @@ -145,7 +137,6 @@ func TestCheckPartialMissing(t *testing.T) { ResetCache() dir := t.TempDir() - // One file exists, one doesn't if err := os.WriteFile(filepath.Join(dir, "exists.go"), []byte("package a"), 0644); err != nil { t.Fatal(err) } @@ -159,8 +150,51 @@ func TestCheckPartialMissing(t *testing.T) { if result.Status != StatusMissing { t.Fatalf("expected missing, got %s", result.Status) } - if result.DecayFactor >= 0.8 { - t.Fatalf("expected significant decay for partial missing, got %f", result.DecayFactor) + // 1 of 2 missing: decay = 1.0 - (0.5 * 0.8) = 0.6 + if result.DecayFactor < 0.55 || result.DecayFactor > 0.65 { + t.Fatalf("expected decay ~0.6 for 50%% missing, got %f", result.DecayFactor) + } +} + +func TestDecaySmoothCurve(t *testing.T) { + // Verify the decay formula produces a smooth curve with no discontinuities + // decay = 1.0 - (missingRatio * 0.8) + ResetCache() + dir := t.TempDir() + + // Create 4 files, progressively make them missing + for _, name := range []string{"a.go", "b.go", "c.go", "d.go"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("package x"), 0644); err != nil { + t.Fatal(err) + } + } + + tests := []struct { + present []string + missing []string + wantMin float64 + wantMax float64 + }{ + {[]string{"a.go", "b.go", "c.go"}, []string{"gone1.go"}, 0.75, 0.85}, // 1/4 missing: 0.8 + {[]string{"a.go", "b.go"}, []string{"gone1.go", "gone2.go"}, 0.55, 0.65}, // 2/4 missing: 0.6 + {[]string{"a.go"}, []string{"gone1.go", "gone2.go", "gone3.go"}, 0.35, 0.45}, // 3/4 missing: 0.4 + {nil, []string{"gone1.go", "gone2.go", "gone3.go", "gone4.go"}, 0.15, 0.25}, // 4/4 missing: 0.2 + } + + for _, tt := range tests { + ResetCache() + var items []evidenceItem + for _, p := range tt.present { + items = append(items, evidenceItem{FilePath: p}) + } + for _, m := range tt.missing { + items = append(items, evidenceItem{FilePath: m}) + } + result := Check(dir, mustJSON(t, items), time.Now().Add(time.Hour)) + if result.DecayFactor < tt.wantMin || result.DecayFactor > tt.wantMax { + t.Errorf("missing=%d/total=%d: expected decay %.2f-%.2f, got %.4f", + len(tt.missing), len(tt.present)+len(tt.missing), tt.wantMin, tt.wantMax, result.DecayFactor) + } } } @@ -195,7 +229,7 @@ func TestStatCache(t *testing.T) { } // First call: cache miss - info1, err1 := statCached(filePath) + info1, err1 := defaultCache.stat(filePath) if err1 != nil { t.Fatal(err1) } @@ -204,7 +238,7 @@ func TestStatCache(t *testing.T) { os.Remove(filePath) // Second call: should return cached result (file still "exists") - info2, err2 := statCached(filePath) + info2, err2 := defaultCache.stat(filePath) if err2 != nil { t.Fatal("expected cached result, got error") } @@ -217,7 +251,6 @@ func TestCheckSkipsBuildArtifactsInSubdirectory(t *testing.T) { ResetCache() dir := t.TempDir() - // Create file in api/node_modules (monorepo pattern -- should be skipped) nmDir := filepath.Join(dir, "api", "node_modules") if err := os.MkdirAll(nmDir, 0755); err != nil { t.Fatal(err) diff --git a/internal/git/git_test.go b/internal/git/git_test.go index c4f2597..12dc2a9 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -40,15 +40,21 @@ func (m *mockCommander) RunInDir(dir, name string, args ...string) (string, erro func TestGitWrapper_Exit128(t *testing.T) { tests := []struct { - name string - workDir string - responses map[string]struct{ output string; err error } + name string + workDir string + responses map[string]struct { + output string + err error + } wantIsRepo bool }{ { name: "non-git directory returns false", workDir: "/workspace/not-a-repo", - responses: map[string]struct{ output string; err error }{ + responses: map[string]struct { + output string + err error + }{ "/workspace/not-a-repo:git rev-parse --is-inside-work-tree": { err: fmt.Errorf("exit status 128: fatal: not a git repository"), }, @@ -58,7 +64,10 @@ func TestGitWrapper_Exit128(t *testing.T) { { name: "valid git repo returns true", workDir: "/workspace/valid-repo", - responses: map[string]struct{ output string; err error }{ + responses: map[string]struct { + output string + err error + }{ "/workspace/valid-repo:git rev-parse --is-inside-work-tree": { output: "true", }, @@ -68,7 +77,10 @@ func TestGitWrapper_Exit128(t *testing.T) { { name: "empty directory with no response returns false", workDir: "/tmp/empty", - responses: map[string]struct{ output string; err error }{}, + responses: map[string]struct { + output string + err error + }{}, wantIsRepo: false, }, } diff --git a/internal/knowledge/classify.go b/internal/knowledge/classify.go index 0129010..d2b7176 100644 --- a/internal/knowledge/classify.go +++ b/internal/knowledge/classify.go @@ -5,6 +5,7 @@ package knowledge import ( "context" + "errors" "fmt" "io" "strings" @@ -47,7 +48,7 @@ func Classify(ctx context.Context, content string, cfg llm.Config) (*ClassifyRes var sb strings.Builder for { chunk, err := stream.Recv() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { diff --git a/internal/knowledge/context.go b/internal/knowledge/context.go new file mode 100644 index 0000000..c3d1010 --- /dev/null +++ b/internal/knowledge/context.go @@ -0,0 +1,308 @@ +// Package knowledge provides unified project context retrieval. +// GetProjectContext is the single source of truth for all context consumers: +// planning, task creation, hooks, and MCP tools. +package knowledge + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/josephgoksu/TaskWing/internal/freshness" + "github.com/josephgoksu/TaskWing/internal/memory" + "github.com/josephgoksu/TaskWing/internal/utils" +) + +// ContextOptions controls what gets retrieved and how deep. +type ContextOptions struct { + // Query is the goal or question to retrieve context for. + // Used to generate search queries and find relevant nodes. + Query string + + // IncludeArchitectureMD loads .taskwing/ARCHITECTURE.md content. + // Default: true + IncludeArchitectureMD bool + + // IncludeConstraints fetches all constraint-type nodes explicitly. + // Default: true + IncludeConstraints bool + + // IncludeRelevantNodes runs hybrid search for nodes relevant to Query. + // Default: true + IncludeRelevantNodes bool + + // UseLLMQueries uses LLM to generate optimized search queries from Query. + // When false, uses Query directly as the search term. + // Default: true + UseLLMQueries bool + + // IncludeSymbols adds code symbol search results. + // Default: false (opt-in) + IncludeSymbols bool + + // MaxNodes caps the total number of nodes returned. + // Default: 15 + MaxNodes int + + // NodesPerQuery limits results per individual search query. + // Default: 3 + NodesPerQuery int + + // CheckFreshness annotates nodes with stale/missing tags. + // Default: true + CheckFreshness bool + + // BasePath is the project root for freshness checks and ARCHITECTURE.md. + // Resolved from config if empty. + BasePath string + + // MemoryBasePath is the .taskwing/memory path. + // Resolved from config if empty. + MemoryBasePath string +} + +// DefaultContextOptions returns options optimized for rich planning-grade context. +func DefaultContextOptions() ContextOptions { + return ContextOptions{ + IncludeArchitectureMD: true, + IncludeConstraints: true, + IncludeRelevantNodes: true, + UseLLMQueries: true, + IncludeSymbols: false, + MaxNodes: 15, + NodesPerQuery: 3, + CheckFreshness: true, + } +} + +// ProjectContext is the unified payload returned by GetProjectContext. +// All context consumers (planning, tasks, hooks) use this same structure. +type ProjectContext struct { + // ArchitectureMD is the full content of .taskwing/ARCHITECTURE.md. + ArchitectureMD string + + // Constraints are all constraint-type nodes from the knowledge graph. + Constraints []memory.Node + + // RelevantNodes are nodes matching the query, sorted by relevance score. + RelevantNodes []ScoredNode + + // SearchLog records what retrieval steps were taken (for debugging). + SearchLog []string +} + +// Format renders the context as a single string suitable for LLM prompt injection. +// This is the canonical formatting -- all consumers use this instead of custom formatting. +func (pc *ProjectContext) Format() string { + var sb strings.Builder + + // ARCHITECTURE.md first (most comprehensive) + if pc.ArchitectureMD != "" { + sb.WriteString("## PROJECT ARCHITECTURE OVERVIEW\n") + sb.WriteString("Consolidated architecture document for this codebase:\n\n") + sb.WriteString(pc.ArchitectureMD) + sb.WriteString("\n---\n\n") + } + + // Constraints (highlighted for emphasis) + if len(pc.Constraints) > 0 { + sb.WriteString("## MANDATORY ARCHITECTURAL CONSTRAINTS\n") + sb.WriteString("These rules MUST be obeyed by all generated tasks.\n\n") + for _, n := range pc.Constraints { + sb.WriteString(fmt.Sprintf("- **%s**: %s\n", n.Summary, n.Text())) + } + sb.WriteString("\n") + } + + // Relevant nodes + if len(pc.RelevantNodes) > 0 { + sb.WriteString("## RELEVANT ARCHITECTURAL CONTEXT\n") + for _, node := range pc.RelevantNodes { + if node.Node == nil { + continue + } + sb.WriteString(fmt.Sprintf("### [%s] %s\n%s\n", node.Node.Type, node.Node.Summary, node.Node.Text())) + + // Evidence file paths + if node.Node.Evidence != "" { + var evidenceList []struct { + FilePath string `json:"file_path"` + StartLine int `json:"start_line"` + } + if json.Unmarshal([]byte(node.Node.Evidence), &evidenceList) == nil && len(evidenceList) > 0 { + sb.WriteString("Referenced files: ") + for i, ev := range evidenceList { + if i > 0 { + sb.WriteString(", ") + } + if ev.StartLine > 0 { + sb.WriteString(fmt.Sprintf("%s:L%d", ev.FilePath, ev.StartLine)) + } else { + sb.WriteString(ev.FilePath) + } + } + sb.WriteString("\n") + } + } + sb.WriteString("\n") + } + } + + return sb.String() +} + +// FormatCompact returns a shorter version suitable for task context embedding. +// Omits ARCHITECTURE.md (too large for per-task context) and truncates content. +func (pc *ProjectContext) FormatCompact() string { + var sb strings.Builder + + if len(pc.Constraints) > 0 { + sb.WriteString("## Architectural Constraints\n") + for _, n := range pc.Constraints { + sb.WriteString(fmt.Sprintf("- **%s**: %s\n", n.Summary, utils.Truncate(n.Text(), 200))) + } + sb.WriteString("\n") + } + + if len(pc.RelevantNodes) > 0 { + sb.WriteString("## Relevant Context\n") + for _, node := range pc.RelevantNodes { + if node.Node == nil { + continue + } + content := utils.Truncate(node.Node.Text(), 300) + sb.WriteString(fmt.Sprintf("- **%s** (%s): %s\n", node.Node.Summary, node.Node.Type, content)) + } + } + + return sb.String() +} + +// GetProjectContext performs unified project context retrieval. +// This is the single source of truth for all context consumers. +// Always reads live from the database -- no caching. +func GetProjectContext(ctx context.Context, svc *Service, opts ContextOptions) (*ProjectContext, error) { + pc := &ProjectContext{} + + // Resolve paths + basePath := opts.BasePath + memoryBasePath := opts.MemoryBasePath + if basePath == "" && memoryBasePath != "" { + basePath = filepath.Dir(filepath.Dir(memoryBasePath)) + } + + // 1. Load ARCHITECTURE.md + if opts.IncludeArchitectureMD && basePath != "" { + archPath := filepath.Join(basePath, ".taskwing", "ARCHITECTURE.md") + if content, err := os.ReadFile(archPath); err == nil { + pc.ArchitectureMD = string(content) + pc.SearchLog = append(pc.SearchLog, "Loaded ARCHITECTURE.md") + } + } + + // 2. Fetch all constraints + if opts.IncludeConstraints { + constraints, err := svc.ListNodesByType(ctx, memory.NodeTypeConstraint) + if err != nil { + pc.SearchLog = append(pc.SearchLog, fmt.Sprintf("Constraint fetch failed: %v", err)) + return pc, fmt.Errorf("constraint fetch: %w", err) + } + if len(constraints) > 0 { + // Annotate freshness + if opts.CheckFreshness && basePath != "" { + for i := range constraints { + annotateFreshness(&constraints[i], basePath) + } + } + pc.Constraints = constraints + pc.SearchLog = append(pc.SearchLog, fmt.Sprintf("Loaded %d constraints", len(constraints))) + } + } + + // 3. Hybrid search for relevant nodes + if opts.IncludeRelevantNodes && opts.Query != "" { + var queries []string + + if opts.UseLLMQueries { + generated, err := svc.SuggestContextQueries(ctx, opts.Query) + if err == nil && len(generated) > 0 { + queries = generated + } else { + queries = []string{opts.Query, "Technology Stack"} + } + } else { + queries = []string{opts.Query} + } + + // Execute searches with deduplication + uniqueNodes := make(map[string]ScoredNode) + for _, q := range queries { + nodes, err := svc.Search(ctx, q, opts.NodesPerQuery) + if err != nil { + pc.SearchLog = append(pc.SearchLog, fmt.Sprintf("Search failed for '%s': %v", q, err)) + continue + } + for _, sn := range nodes { + if existing, exists := uniqueNodes[sn.Node.ID]; !exists || sn.Score > existing.Score { + uniqueNodes[sn.Node.ID] = sn + } + } + pc.SearchLog = append(pc.SearchLog, fmt.Sprintf("Searched: '%s'", q)) + } + + // Sort by score, cap at MaxNodes + var allNodes []ScoredNode + for _, sn := range uniqueNodes { + allNodes = append(allNodes, sn) + } + sort.Slice(allNodes, func(i, j int) bool { + return allNodes[i].Score > allNodes[j].Score + }) + if opts.MaxNodes > 0 && len(allNodes) > opts.MaxNodes { + allNodes = allNodes[:opts.MaxNodes] + } + + // Annotate freshness + if opts.CheckFreshness && basePath != "" { + for i := range allNodes { + annotateFreshnessScored(&allNodes[i], basePath) + } + } + + pc.RelevantNodes = allNodes + pc.SearchLog = append(pc.SearchLog, fmt.Sprintf("Found %d relevant nodes", len(allNodes))) + } + + return pc, nil +} + +// annotateFreshness adds a stale tag to a node's Summary if its evidence is stale. +func annotateFreshness(n *memory.Node, basePath string) { + if n.Evidence == "" { + return + } + result := freshness.Check(basePath, n.Evidence, n.CreatedAt) + if result.Status == freshness.StatusStale { + n.Summary += " [STALE]" + } else if result.Status == freshness.StatusMissing { + n.Summary += " [MISSING]" + } +} + +// annotateFreshnessScored adds a stale tag to a scored node. +func annotateFreshnessScored(sn *ScoredNode, basePath string) { + if sn.Node == nil || sn.Node.Evidence == "" { + return + } + result := freshness.Check(basePath, sn.Node.Evidence, sn.Node.CreatedAt) + if result.Status == freshness.StatusStale { + sn.Node.Summary += " [STALE]" + } else if result.Status == freshness.StatusMissing { + sn.Node.Summary += " [MISSING]" + } +} + diff --git a/internal/knowledge/ingest.go b/internal/knowledge/ingest.go index 84fe143..9f251ff 100644 --- a/internal/knowledge/ingest.go +++ b/internal/knowledge/ingest.go @@ -32,14 +32,14 @@ func (s *Service) IngestFindingsWithRelationships(ctx context.Context, findings } // 0. Verify Findings (if basePath is set) - verifiedCount, rejectedCount := 0, 0 + rejectedCount := 0 if s.basePath != "" { if verbose { - fmt.Print(" Verifying evidence") + fmt.Print(" Verifying evidence...") } - findings, verifiedCount, rejectedCount = s.verifyFindings(ctx, findings, verbose) + findings, _, rejectedCount = s.verifyFindings(ctx, findings, verbose) if verbose { - fmt.Printf(" done (%d verified, %d rejected)\n", verifiedCount, rejectedCount) + fmt.Println() } } @@ -49,7 +49,7 @@ func (s *Service) IngestFindingsWithRelationships(ctx context.Context, findings } // 2. Ingest Nodes (Documents) - nodesCreated, skippedDuplicates, nodesByTitle, err := s.ingestNodesWithIndex(ctx, findings, verbose) + nodesCreated, _, nodesByTitle, err := s.ingestNodesWithIndex(ctx, findings, verbose) if err != nil { return err } @@ -68,10 +68,9 @@ func (s *Service) IngestFindingsWithRelationships(ctx context.Context, findings if verbose { fmt.Println(" done") if rejectedCount > 0 { - fmt.Printf("\n⚠️ Rejected %d findings with unverifiable evidence.\n", rejectedCount) + fmt.Printf(" %d findings rejected (unverifiable evidence)\n", rejectedCount) } - fmt.Printf("\n✅ Saved %d knowledge nodes (%d duplicates skipped), %d edges (%d evidence, %d semantic, %d llm) to memory.\n", - nodesCreated, skippedDuplicates, totalEdges, evidenceEdges, semanticEdges, llmEdges) + fmt.Printf(" Saved %d nodes, %d edges\n", nodesCreated, totalEdges) } return nil @@ -90,27 +89,26 @@ func (s *Service) verifyFindings(ctx context.Context, findings []core.Finding, v // Count results verifiedCount := 0 rejectedCount := 0 + partialCount := 0 for _, f := range verified { switch f.VerificationStatus { case core.VerificationStatusVerified: verifiedCount++ - if verbose { - fmt.Print("✓") - } case core.VerificationStatusPartial: - verifiedCount++ // Partial counts as verified (kept) - if verbose { - fmt.Print("~") - } + verifiedCount++ + partialCount++ case core.VerificationStatusRejected: rejectedCount++ - if verbose { - fmt.Print("✗") - } - default: - if verbose { - fmt.Print(".") - } + } + } + + if verbose { + fmt.Printf(" %d verified", verifiedCount) + if partialCount > 0 { + fmt.Printf(" (%d partial)", partialCount) + } + if rejectedCount > 0 { + fmt.Printf(", %d rejected", rejectedCount) } } @@ -152,7 +150,7 @@ func (s *Service) purgeStaleData(findings []core.Finding, filePaths []string, ve // ingestNodesWithIndex creates document nodes and returns a title->nodeID index for LLM relationship linking func (s *Service) ingestNodesWithIndex(ctx context.Context, findings []core.Finding, verbose bool) (int, int, map[string]string, error) { if verbose { - fmt.Print(" Generating embeddings") + fmt.Printf(" Generating embeddings for %d findings...", len(findings)) } nodesCreated := 0 @@ -260,9 +258,6 @@ func (s *Service) ingestNodesWithIndex(ctx context.Context, findings []core.Find if s.llmCfg.APIKey != "" { if embedding, err := GenerateEmbedding(ctx, node.Text(), s.llmCfg); err == nil { node.Embedding = embedding - if verbose { - fmt.Print(".") - } } } @@ -273,6 +268,9 @@ func (s *Service) ingestNodesWithIndex(ctx context.Context, findings []core.Find nodesByTitle[strings.ToLower(f.Title)] = nodeID } } + if verbose { + fmt.Printf(" %d created\n", nodesCreated) + } return nodesCreated, skippedDuplicates, nodesByTitle, nil } @@ -438,6 +436,7 @@ func (s *Service) linkByLLMRelationships(relationships []core.Relationship, node } count := 0 + linkErrors := 0 for _, rel := range relationships { // Look up node IDs by title (case-insensitive) fromID := nodesByTitle[strings.ToLower(rel.From)] @@ -474,12 +473,17 @@ func (s *Service) linkByLLMRelationships(relationships []core.Relationship, node } if err := s.repo.LinkNodes(fromID, toID, relationType, weight, props); err != nil { - fmt.Fprintf(os.Stderr, "⚠️ failed to link nodes (llm): %v\n", err) + linkErrors++ } else { count++ } } + // Single summary instead of per-failure warnings + if linkErrors > 0 { + fmt.Fprintf(os.Stderr, "⚠️ %d LLM relationship links skipped (node title mismatches)\n", linkErrors) + } + return count } diff --git a/internal/llm/models.go b/internal/llm/models.go index 8303160..4a9fd4c 100644 --- a/internal/llm/models.go +++ b/internal/llm/models.go @@ -47,21 +47,48 @@ const DefaultMaxInputTokens = 8192 // ModelRegistry is the single source of truth for all supported models. // Add new models here - everything else derives from this registry. -// Prices last updated: 2025-12 (via web research) +// Models within each provider section are ordered NEWEST FIRST. +// This order is used by the selection UI to show the most recent models at the top. +// Prices last updated: 2026-03 (via web research) var ModelRegistry = []Model{ // ============================================ - // OpenAI Models (2025) - // https://platform.openai.com/docs/models + // OpenAI Models (newest first) + // https://developers.openai.com/api/docs/models + // Prices verified: 2026-03-17 // ============================================ { - ID: "o3", + ID: "gpt-5.4", Provider: "OpenAI", ProviderID: ProviderOpenAI, - InputPer1M: 0.40, - OutputPer1M: 1.60, + Aliases: []string{"gpt-5.4-2026-03-05"}, + InputPer1M: 2.50, + OutputPer1M: 15.00, SupportsThinking: true, Category: CategoryReasoning, - MaxInputTokens: 200_000, + MaxInputTokens: 1_050_000, + }, + { + ID: "gpt-5.4-mini", + Provider: "OpenAI", + ProviderID: ProviderOpenAI, + Aliases: []string{"gpt-5.4-mini-2026-03-17"}, + InputPer1M: 0.75, + OutputPer1M: 4.50, + IsDefault: true, + SupportsThinking: true, + Category: CategoryBalanced, + MaxInputTokens: 400_000, + }, + { + ID: "gpt-5.4-nano", + Provider: "OpenAI", + ProviderID: ProviderOpenAI, + Aliases: []string{"gpt-5.4-nano-2026-03-17"}, + InputPer1M: 0.20, + OutputPer1M: 1.25, + SupportsThinking: true, + Category: CategoryFast, + MaxInputTokens: 400_000, }, { ID: "o4-mini", @@ -73,6 +100,16 @@ var ModelRegistry = []Model{ Category: CategoryReasoning, MaxInputTokens: 200_000, }, + { + ID: "o3", + Provider: "OpenAI", + ProviderID: ProviderOpenAI, + InputPer1M: 2.00, + OutputPer1M: 8.00, + SupportsThinking: true, + Category: CategoryReasoning, + MaxInputTokens: 200_000, + }, { ID: "gpt-5", Provider: "OpenAI", @@ -80,7 +117,7 @@ var ModelRegistry = []Model{ InputPer1M: 1.25, OutputPer1M: 10.00, Category: CategoryReasoning, - MaxInputTokens: 128_000, + MaxInputTokens: 400_000, }, { ID: "gpt-5-mini", @@ -88,9 +125,8 @@ var ModelRegistry = []Model{ ProviderID: ProviderOpenAI, InputPer1M: 0.25, OutputPer1M: 2.00, - IsDefault: true, Category: CategoryBalanced, - MaxInputTokens: 128_000, + MaxInputTokens: 400_000, }, { ID: "gpt-5-nano", @@ -99,7 +135,7 @@ var ModelRegistry = []Model{ InputPer1M: 0.05, OutputPer1M: 0.40, Category: CategoryFast, - MaxInputTokens: 128_000, + MaxInputTokens: 400_000, }, { ID: "gpt-4.1", @@ -108,7 +144,7 @@ var ModelRegistry = []Model{ InputPer1M: 2.00, OutputPer1M: 8.00, Category: CategoryReasoning, - MaxInputTokens: 1_000_000, + MaxInputTokens: 1_047_576, }, { ID: "gpt-4.1-mini", @@ -117,7 +153,7 @@ var ModelRegistry = []Model{ InputPer1M: 0.40, OutputPer1M: 1.60, Category: CategoryBalanced, - MaxInputTokens: 1_000_000, + MaxInputTokens: 1_047_576, }, { ID: "gpt-4.1-nano", @@ -126,52 +162,72 @@ var ModelRegistry = []Model{ InputPer1M: 0.10, OutputPer1M: 0.40, Category: CategoryFast, - MaxInputTokens: 1_000_000, + MaxInputTokens: 1_047_576, }, // ============================================ - // Anthropic Claude 4.x Models (2025) - // https://docs.anthropic.com/en/docs/about-claude/models + // Anthropic Claude Models (newest first) + // https://platform.claude.com/docs/en/docs/about-claude/models // ============================================ { - ID: "claude-sonnet-4-5", + ID: "claude-opus-4-6", + Provider: "Anthropic", + ProviderID: ProviderAnthropic, + InputPer1M: 5.00, + OutputPer1M: 25.00, + SupportsThinking: true, + Category: CategoryReasoning, + MaxInputTokens: 1_000_000, + }, + { + ID: "claude-sonnet-4-6", Provider: "Anthropic", ProviderID: ProviderAnthropic, - Aliases: []string{"claude-sonnet-4-5-20250929"}, InputPer1M: 3.00, OutputPer1M: 15.00, IsDefault: true, SupportsThinking: true, Category: CategoryBalanced, + MaxInputTokens: 1_000_000, + }, + { + ID: "claude-haiku-4-5", + Provider: "Anthropic", + ProviderID: ProviderAnthropic, + Aliases: []string{"claude-haiku-4-5-20251001"}, + InputPer1M: 1.00, + OutputPer1M: 5.00, + SupportsThinking: true, + Category: CategoryFast, MaxInputTokens: 200_000, }, { - ID: "claude-opus-4-5", + ID: "claude-sonnet-4-5", Provider: "Anthropic", ProviderID: ProviderAnthropic, - Aliases: []string{"claude-opus-4-5-20251101"}, - InputPer1M: 5.00, - OutputPer1M: 25.00, + Aliases: []string{"claude-sonnet-4-5-20250929"}, + InputPer1M: 3.00, + OutputPer1M: 15.00, SupportsThinking: true, - Category: CategoryReasoning, + Category: CategoryBalanced, MaxInputTokens: 200_000, }, { - ID: "claude-haiku-4-5", + ID: "claude-opus-4-5", Provider: "Anthropic", ProviderID: ProviderAnthropic, - Aliases: []string{"claude-haiku-4-5-20251001"}, - InputPer1M: 1.00, - OutputPer1M: 5.00, + Aliases: []string{"claude-opus-4-5-20251101"}, + InputPer1M: 5.00, + OutputPer1M: 25.00, SupportsThinking: true, - Category: CategoryFast, + Category: CategoryReasoning, MaxInputTokens: 200_000, }, { ID: "claude-sonnet-4", Provider: "Anthropic", ProviderID: ProviderAnthropic, - Aliases: []string{"claude-sonnet-4-20250514"}, + Aliases: []string{"claude-sonnet-4-20250514", "claude-sonnet-4-0"}, InputPer1M: 3.00, OutputPer1M: 15.00, SupportsThinking: true, @@ -191,39 +247,65 @@ var ModelRegistry = []Model{ }, // ============================================ - // AWS Bedrock OpenAI-Compatible Models (curated) + // AWS Bedrock OpenAI-Compatible Models (newest first) // Sources: // - https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html // - https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html // ============================================ { - ID: "anthropic.claude-sonnet-4-5-20250929-v1:0", + ID: "anthropic.claude-opus-4-6-v1", + Provider: "AWS Bedrock", + ProviderID: ProviderBedrock, + InputPer1M: 5.00, + OutputPer1M: 25.00, + SupportsThinking: true, + Category: CategoryReasoning, + MaxInputTokens: 1_000_000, + }, + { + ID: "anthropic.claude-sonnet-4-6", Provider: "AWS Bedrock", ProviderID: ProviderBedrock, Aliases: []string{ - "us.anthropic.claude-sonnet-4-5-20250929-v1:0", - "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", - "apac.anthropic.claude-sonnet-4-5-20250929-v1:0", + "us.anthropic.claude-sonnet-4-6", + "eu.anthropic.claude-sonnet-4-6", }, InputPer1M: 3.00, OutputPer1M: 15.00, IsDefault: true, SupportsThinking: true, Category: CategoryBalanced, - MaxInputTokens: 200_000, + MaxInputTokens: 1_000_000, }, { - ID: "anthropic.claude-opus-4-6-v1", + ID: "anthropic.claude-haiku-4-5-20251001-v1:0", Provider: "AWS Bedrock", ProviderID: ProviderBedrock, SupportsThinking: true, - Category: CategoryReasoning, + Category: CategoryFast, + MaxInputTokens: 200_000, + }, + { + ID: "anthropic.claude-sonnet-4-5-20250929-v1:0", + Provider: "AWS Bedrock", + ProviderID: ProviderBedrock, + Aliases: []string{ + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "apac.anthropic.claude-sonnet-4-5-20250929-v1:0", + }, + InputPer1M: 3.00, + OutputPer1M: 15.00, + SupportsThinking: true, + Category: CategoryBalanced, MaxInputTokens: 200_000, }, { ID: "anthropic.claude-opus-4-5-20251101-v1:0", Provider: "AWS Bedrock", ProviderID: ProviderBedrock, + InputPer1M: 5.00, + OutputPer1M: 25.00, SupportsThinking: true, Category: CategoryReasoning, MaxInputTokens: 200_000, @@ -238,14 +320,6 @@ var ModelRegistry = []Model{ Category: CategoryReasoning, MaxInputTokens: 200_000, }, - { - ID: "anthropic.claude-haiku-4-5-20251001-v1:0", - Provider: "AWS Bedrock", - ProviderID: ProviderBedrock, - SupportsThinking: true, - Category: CategoryFast, - MaxInputTokens: 200_000, - }, { ID: "amazon.nova-premier-v1:0", Provider: "AWS Bedrock", @@ -301,12 +375,12 @@ var ModelRegistry = []Model{ }, // ============================================ - // Google Gemini Models (2025) + // Google Gemini Models (newest first) // https://ai.google.dev/gemini-api/docs/models - // Note: Gemini 1.5 retired April 2025 + // https://ai.google.dev/pricing // ============================================ { - ID: "gemini-3-pro-preview", + ID: "gemini-3.1-pro-preview", Provider: "Google", ProviderID: ProviderGemini, InputPer1M: 2.00, @@ -315,6 +389,16 @@ var ModelRegistry = []Model{ Category: CategoryReasoning, MaxInputTokens: 1_000_000, }, + { + ID: "gemini-3.1-flash-lite-preview", + Provider: "Google", + ProviderID: ProviderGemini, + InputPer1M: 0.25, + OutputPer1M: 1.50, + SupportsThinking: true, + Category: CategoryFast, + MaxInputTokens: 1_000_000, + }, { ID: "gemini-3-flash-preview", Provider: "Google", @@ -341,6 +425,7 @@ var ModelRegistry = []Model{ ProviderID: ProviderGemini, InputPer1M: 0.30, OutputPer1M: 2.50, + IsDefault: true, SupportsThinking: true, Category: CategoryBalanced, MaxInputTokens: 1_000_000, @@ -355,25 +440,6 @@ var ModelRegistry = []Model{ Category: CategoryFast, MaxInputTokens: 1_000_000, }, - { - ID: "gemini-2.0-flash", - Provider: "Google", - ProviderID: ProviderGemini, - InputPer1M: 0.10, - OutputPer1M: 0.40, - IsDefault: true, - Category: CategoryBalanced, - MaxInputTokens: 1_000_000, - }, - { - ID: "gemini-2.0-flash-lite", - Provider: "Google", - ProviderID: ProviderGemini, - InputPer1M: 0.075, - OutputPer1M: 0.30, - Category: CategoryFast, - MaxInputTokens: 1_000_000, - }, // ============================================ // Ollama Models (local, no pricing) @@ -498,20 +564,6 @@ func GetRecommendedModelForRole(providerID string, role ModelRole) *Model { return GetDefaultModel(providerID) } -// GetCategoryBadge returns an emoji badge for the model category. -func GetCategoryBadge(category ModelCategory) string { - switch category { - case CategoryReasoning: - return "🧠" - case CategoryBalanced: - return "⚡" - case CategoryFast: - return "🚀" - default: - return "" - } -} - // InferProvider attempts to determine the provider from a model name. // Returns the provider ID and true if inference succeeded. func InferProvider(modelID string) (string, bool) { @@ -567,40 +619,40 @@ type ModelOption struct { IsDefault bool } -// ModelOption represents a model choice for selection UI -type modelWithPrice struct { - option ModelOption - totalPrice float64 // input + output for sorting +// modelWithOrder tracks a model's position in the registry for stable ordering. +type modelWithOrder struct { + option ModelOption + registryIdx int // Position in ModelRegistry (newest first per provider) } // GetModelsForProvider returns available models for a provider (for UI selection). -// Models are sorted: default first, then by total price (cheapest to most expensive). +// Models are sorted: default first, then in registry order (newest to oldest). +// The ModelRegistry is the source of truth for ordering -- list newer models first. func GetModelsForProvider(providerID string) []ModelOption { - var models []modelWithPrice + var models []modelWithOrder - for _, m := range ModelRegistry { + for i, m := range ModelRegistry { if m.ProviderID != providerID { continue } - models = append(models, modelWithPrice{ + models = append(models, modelWithOrder{ option: ModelOption{ ID: m.ID, DisplayName: m.ID, PriceInfo: formatPriceInfo(m.ProviderID, m.InputPer1M, m.OutputPer1M), IsDefault: m.IsDefault, }, - totalPrice: m.InputPer1M + m.OutputPer1M, + registryIdx: i, }) } - // Sort: default first, then by price descending (most capable/expensive first) - // Price is a reasonable proxy for capability - users want latest/best models first + // Sort: default first, then by registry order (newest to oldest) sort.Slice(models, func(i, j int) bool { if models[i].option.IsDefault != models[j].option.IsDefault { return models[i].option.IsDefault } - return models[i].totalPrice > models[j].totalPrice // descending + return models[i].registryIdx < models[j].registryIdx }) options := make([]ModelOption, len(models)) diff --git a/internal/llm/tokens.go b/internal/llm/tokens.go index 770c0c1..5793395 100644 --- a/internal/llm/tokens.go +++ b/internal/llm/tokens.go @@ -11,9 +11,3 @@ func EstimateTokens(text string) int { // Standard heuristic: 1 token ≈ 4 characters return (len(text) + 3) / 4 // Round up to be conservative } - -// EstimateBudgetChars converts a token budget to approximate character limit. -// Use this when you want to enforce a character-based limit from a token budget. -func EstimateBudgetChars(tokens int) int { - return tokens * 4 -} diff --git a/internal/logger/crash.go b/internal/logger/crash.go index b73c112..8da022d 100644 --- a/internal/logger/crash.go +++ b/internal/logger/crash.go @@ -156,7 +156,7 @@ func writeCrashLog(log CrashLog) error { path := getCrashLogPath(log.Timestamp) content := formatCrashLog(log) - if err := os.WriteFile(path, []byte(content), 0644); err != nil { + if err := os.WriteFile(path, []byte(content), 0600); err != nil { return fmt.Errorf("write crash log: %w", err) } diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 8d2f4c7..aa565a4 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -16,6 +16,7 @@ import ( "github.com/josephgoksu/TaskWing/internal/config" "github.com/josephgoksu/TaskWing/internal/llm" "github.com/josephgoksu/TaskWing/internal/memory" + "github.com/josephgoksu/TaskWing/internal/safepath" ) // CodeToolResult represents the response from the unified code tool. @@ -361,43 +362,23 @@ func readFileContent(path string) (string, error) { } // validateAndResolvePath validates a file path to prevent path traversal attacks. +// Uses safepath.SafeJoin which resolves symlinks and prevents escape from base directory. // Returns the resolved absolute path if valid, or an error if the path is unsafe. func validateAndResolvePath(requestedPath string, projectRoot string) (string, error) { - // Clean the path to normalize it - cleanPath := filepath.Clean(requestedPath) - - // Reject paths with explicit traversal attempts - if strings.Contains(cleanPath, "..") { - return "", fmt.Errorf("path traversal not allowed: %s", requestedPath) + if projectRoot == "" { + return "", fmt.Errorf("cannot resolve path without project root") } - // Resolve to absolute path + // Support both relative and absolute paths within the project root var absPath string - if filepath.IsAbs(cleanPath) { - absPath = cleanPath + var err error + if filepath.IsAbs(requestedPath) { + absPath, err = safepath.ValidateAbsPath(projectRoot, requestedPath) } else { - if projectRoot == "" { - return "", fmt.Errorf("cannot resolve relative path without project root") - } - absPath = filepath.Join(projectRoot, cleanPath) + absPath, err = safepath.SafeJoin(projectRoot, requestedPath) } - - // Ensure the resolved path is within the project root - if projectRoot != "" { - absProjectRoot, err := filepath.Abs(projectRoot) - if err != nil { - return "", fmt.Errorf("failed to resolve project root: %w", err) - } - absPath, err = filepath.Abs(absPath) - if err != nil { - return "", fmt.Errorf("failed to resolve path: %w", err) - } - - // Check that the path starts with the project root - if !strings.HasPrefix(absPath, absProjectRoot+string(filepath.Separator)) && - absPath != absProjectRoot { - return "", fmt.Errorf("path outside project root not allowed: %s", requestedPath) - } + if err != nil { + return "", fmt.Errorf("path not allowed: %w", err) } // Verify the file exists and is a regular file diff --git a/internal/mcp/presenter.go b/internal/mcp/presenter.go index e8a6856..57b27e4 100644 --- a/internal/mcp/presenter.go +++ b/internal/mcp/presenter.go @@ -18,7 +18,7 @@ import ( ) // FormatAsk converts an AskResult into token-efficient Markdown. -// Structure: Answer (if present) -> Knowledge -> Symbols +// Structure: Answer (if present) -> Knowledge (grouped by type) -> Symbols // Includes debt warnings for patterns/decisions marked as technical debt. func FormatAsk(result *app.AskResult) string { if result == nil { @@ -34,29 +34,53 @@ func FormatAsk(result *app.AskResult) string { sb.WriteString("\n\n") } - // Knowledge section + // Knowledge section - grouped by type for clarity if len(result.Results) > 0 { sb.WriteString("## Knowledge\n") - for i, node := range result.Results { - // Format: 1. **Title** (type) [freshness] - content preview - sb.WriteString(fmt.Sprintf("%d. **%s** (%s)", i+1, node.Summary, node.Type)) - if node.FreshnessNote != "" { - sb.WriteString(fmt.Sprintf(" %s", node.FreshnessNote)) + + // Group nodes by type for better structure + typeOrder := []string{"decision", "feature", "constraint", "pattern", "plan", "note", "metadata", "documentation"} + grouped := make(map[string][]knowledge.NodeResponse) + for _, node := range result.Results { + grouped[node.Type] = append(grouped[node.Type], node) + } + + idx := 1 + for _, t := range typeOrder { + nodes := grouped[t] + if len(nodes) == 0 { + continue } - if node.Content != "" && node.Content != node.Summary { - // Add content preview (first 150 chars) - content := cleanContent(node.Content, node.Summary) - if content != "" { - preview := truncate(content, 150) - sb.WriteString(fmt.Sprintf("\n %s", preview)) + for _, node := range nodes { + sb.WriteString(fmt.Sprintf("%d. **%s** (%s)", idx, node.Summary, node.Type)) + if node.FreshnessNote != "" { + sb.WriteString(fmt.Sprintf(" %s", node.FreshnessNote)) + } + if node.Content != "" && node.Content != node.Summary { + content := cleanContent(node.Content, node.Summary) + if content != "" { + // Show more content (300 chars) so the consuming LLM has enough + // context to produce comprehensive answers + preview := truncate(content, 300) + sb.WriteString(fmt.Sprintf("\n %s", preview)) + } + } + if node.DebtWarning != "" { + sb.WriteString(fmt.Sprintf("\n %s", node.DebtWarning)) } + sb.WriteString("\n") + idx++ } - // Add debt warning if this is technical debt - if node.DebtWarning != "" { - sb.WriteString(fmt.Sprintf("\n %s", node.DebtWarning)) + } + + // Include any types not in the standard order + for _, node := range result.Results { + if _, ok := grouped[node.Type]; !ok { + sb.WriteString(fmt.Sprintf("%d. **%s** (%s)\n", idx, node.Summary, node.Type)) + idx++ } - sb.WriteString("\n") } + sb.WriteString("\n") } @@ -607,7 +631,7 @@ func FormatSummary(summary *knowledge.ProjectSummary) string { if len(summary.Types) > 0 { // Sort types for consistent output - typeOrder := []string{"decision", "pattern", "constraint", "feature", "plan", "note"} + typeOrder := []string{"decision", "feature", "constraint", "pattern", "plan", "note", "metadata", "documentation"} for _, typeName := range typeOrder { if ts, ok := summary.Types[typeName]; ok && ts.Count > 0 { icon := typeIcon(typeName) diff --git a/internal/memory/sqlite.go b/internal/memory/sqlite.go index aee1563..4361082 100644 --- a/internal/memory/sqlite.go +++ b/internal/memory/sqlite.go @@ -614,17 +614,17 @@ func (s *SQLiteStore) initSchema() error { column string ddl string }{ - {"scope", "ALTER TABLE tasks ADD COLUMN scope TEXT"}, // e.g., "auth", "api", "vectorsearch" - {"keywords", "ALTER TABLE tasks ADD COLUMN keywords TEXT"}, // JSON array of extracted keywords + {"scope", "ALTER TABLE tasks ADD COLUMN scope TEXT"}, // e.g., "auth", "api", "vectorsearch" + {"keywords", "ALTER TABLE tasks ADD COLUMN keywords TEXT"}, // JSON array of extracted keywords {"suggested_ask_queries", "ALTER TABLE tasks ADD COLUMN suggested_ask_queries TEXT"}, // JSON array of pre-computed ask queries - {"claimed_by", "ALTER TABLE tasks ADD COLUMN claimed_by TEXT"}, // Session ID that claimed this task - {"claimed_at", "ALTER TABLE tasks ADD COLUMN claimed_at TEXT"}, // Timestamp when claimed - {"completed_at", "ALTER TABLE tasks ADD COLUMN completed_at TEXT"}, // Timestamp when completed - {"completion_summary", "ALTER TABLE tasks ADD COLUMN completion_summary TEXT"}, // AI-generated summary on completion - {"files_modified", "ALTER TABLE tasks ADD COLUMN files_modified TEXT"}, // JSON array of modified files - {"block_reason", "ALTER TABLE tasks ADD COLUMN block_reason TEXT"}, // Reason if task is blocked - {"expected_files", "ALTER TABLE tasks ADD COLUMN expected_files TEXT"}, // JSON array of expected files (for Sentinel) - {"git_baseline", "ALTER TABLE tasks ADD COLUMN git_baseline TEXT"}, // JSON array of files already modified at task start + {"claimed_by", "ALTER TABLE tasks ADD COLUMN claimed_by TEXT"}, // Session ID that claimed this task + {"claimed_at", "ALTER TABLE tasks ADD COLUMN claimed_at TEXT"}, // Timestamp when claimed + {"completed_at", "ALTER TABLE tasks ADD COLUMN completed_at TEXT"}, // Timestamp when completed + {"completion_summary", "ALTER TABLE tasks ADD COLUMN completion_summary TEXT"}, // AI-generated summary on completion + {"files_modified", "ALTER TABLE tasks ADD COLUMN files_modified TEXT"}, // JSON array of modified files + {"block_reason", "ALTER TABLE tasks ADD COLUMN block_reason TEXT"}, // Reason if task is blocked + {"expected_files", "ALTER TABLE tasks ADD COLUMN expected_files TEXT"}, // JSON array of expected files (for Sentinel) + {"git_baseline", "ALTER TABLE tasks ADD COLUMN git_baseline TEXT"}, // JSON array of files already modified at task start } for _, m := range taskMigrations { @@ -2449,6 +2449,7 @@ func (s *SQLiteStore) NeedsToolUpdate(toolName, expectedVersion string) (bool, e } // UpdateNodeFreshness updates the freshness validation fields for a node. +// TODO(freshness-level2): Called by annotateResultFreshness after persisting check results. func (s *SQLiteStore) UpdateNodeFreshness(nodeID string, lastVerifiedAt time.Time, originalConfidence *float64) error { var origConf sql.NullFloat64 if originalConfidence != nil { diff --git a/internal/migration/upgrade.go b/internal/migration/upgrade.go index b86d29c..4f5b3f1 100644 --- a/internal/migration/upgrade.go +++ b/internal/migration/upgrade.go @@ -65,7 +65,7 @@ func CheckAndMigrate(projectDir, currentVersion string) (warnings []string, err } // migrateLocalConfigs detects which AIs have managed markers and regenerates -// their slash commands (which internally prunes stale tw-* files). +// their slash commands/skills (which internally prunes stale files). func migrateLocalConfigs(projectDir string) { for _, aiName := range bootstrap.ValidAINames() { cfg, ok := bootstrap.AIHelperByName(aiName) @@ -74,21 +74,25 @@ func migrateLocalConfigs(projectDir string) { } // Check if this AI has a managed marker + managed := false if cfg.SingleFile { // Single-file AIs (e.g., Copilot) embed the marker in file content. - // Check for the embedded marker before regenerating. filePath := filepath.Join(projectDir, cfg.CommandsDir, cfg.SingleFileName) content, err := os.ReadFile(filePath) - if err != nil || !strings.Contains(string(content), "") { - continue + if err == nil && strings.Contains(string(content), "") { + managed = true } } else { markerPath := filepath.Join(projectDir, cfg.CommandsDir, bootstrap.TaskWingManagedFile) - if _, err := os.Stat(markerPath); err != nil { - continue + if _, err := os.Stat(markerPath); err == nil { + managed = true } } + if !managed { + continue + } + // Regenerate (this prunes stale files and creates new ones) initializer := bootstrap.NewInitializer(projectDir) if err := initializer.CreateSlashCommands(aiName, false); err != nil { diff --git a/internal/migration/upgrade_test.go b/internal/migration/upgrade_test.go index dddc3fa..9ecb923 100644 --- a/internal/migration/upgrade_test.go +++ b/internal/migration/upgrade_test.go @@ -77,17 +77,17 @@ func TestMigrationRunsOnVersionChange(t *testing.T) { t.Fatal(err) } - // Create a managed Claude commands directory with a legacy tw-ask.md file - claudeDir := filepath.Join(dir, ".claude", "commands") - if err := os.MkdirAll(claudeDir, 0755); err != nil { + // Create a managed Claude commands directory (legacy format) with a flat tw-ask.md file + legacyCmdDir := filepath.Join(dir, ".claude", "commands") + if err := os.MkdirAll(legacyCmdDir, 0755); err != nil { t.Fatal(err) } markerContent := "# This directory is managed by TaskWing\n# AI: claude\n# Version: old\n" - if err := os.WriteFile(filepath.Join(claudeDir, bootstrap.TaskWingManagedFile), []byte(markerContent), 0644); err != nil { + if err := os.WriteFile(filepath.Join(legacyCmdDir, bootstrap.TaskWingManagedFile), []byte(markerContent), 0644); err != nil { t.Fatal(err) } - // Write a legacy tw-ask.md file that should get pruned - if err := os.WriteFile(filepath.Join(claudeDir, "tw-ask.md"), []byte("legacy"), 0644); err != nil { + // Write a legacy tw-ask.md file that should get cleaned up + if err := os.WriteFile(filepath.Join(legacyCmdDir, "tw-ask.md"), []byte("legacy"), 0644); err != nil { t.Fatal(err) } @@ -105,15 +105,21 @@ func TestMigrationRunsOnVersionChange(t *testing.T) { } // Legacy tw-ask.md should be removed - if _, err := os.Stat(filepath.Join(claudeDir, "tw-ask.md")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(legacyCmdDir, "tw-ask.md")); !os.IsNotExist(err) { t.Fatal("legacy tw-ask.md should have been pruned") } - // New namespace directory should exist with regenerated commands - nsDir := filepath.Join(claudeDir, "taskwing") + // Commands namespace directory should exist with embedded content + nsDir := filepath.Join(legacyCmdDir, "taskwing") if _, err := os.Stat(nsDir); os.IsNotExist(err) { t.Fatal("taskwing/ namespace directory should have been created") } + + // At least one command should be generated with embedded content + planCmd := filepath.Join(nsDir, "plan.md") + if _, err := os.Stat(planCmd); os.IsNotExist(err) { + t.Fatal("taskwing/plan.md should have been created") + } } func TestMigrationIdempotent(t *testing.T) { @@ -126,13 +132,13 @@ func TestMigrationIdempotent(t *testing.T) { t.Fatal(err) } - // Create managed Claude config - claudeDir := filepath.Join(dir, ".claude", "commands") - if err := os.MkdirAll(claudeDir, 0755); err != nil { + // Create managed Claude config (legacy commands directory) + legacyCmdDir := filepath.Join(dir, ".claude", "commands") + if err := os.MkdirAll(legacyCmdDir, 0755); err != nil { t.Fatal(err) } markerContent := "# This directory is managed by TaskWing\n# AI: claude\n# Version: old\n" - if err := os.WriteFile(filepath.Join(claudeDir, bootstrap.TaskWingManagedFile), []byte(markerContent), 0644); err != nil { + if err := os.WriteFile(filepath.Join(legacyCmdDir, bootstrap.TaskWingManagedFile), []byte(markerContent), 0644); err != nil { t.Fatal(err) } diff --git a/internal/policy/builtins.go b/internal/policy/builtins.go index a388a5c..53b4f42 100644 --- a/internal/policy/builtins.go +++ b/internal/policy/builtins.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/afero" "github.com/josephgoksu/TaskWing/internal/codeintel" + "github.com/josephgoksu/TaskWing/internal/safepath" ) // BuiltinContext holds dependencies for custom OPA built-ins. @@ -45,11 +46,12 @@ func NewBuiltinContextWithCodeIntel(workDir string, repo codeintel.Repository) * } // resolvePath converts a relative path to absolute using the work directory. -func (bc *BuiltinContext) resolvePath(path string) string { +// Uses safepath for both relative and absolute paths to prevent traversal and prefix collisions. +func (bc *BuiltinContext) resolvePath(path string) (string, error) { if filepath.IsAbs(path) { - return path + return safepath.ValidateAbsPath(bc.WorkDir, path) } - return filepath.Join(bc.WorkDir, path) + return safepath.SafeJoin(bc.WorkDir, path) } // RegisterBuiltins registers all TaskWing custom built-ins with OPA. @@ -173,7 +175,10 @@ func RegisterBuiltins(ctx *BuiltinContext) []string { // fileLineCountImpl returns the number of lines in a file, or -1 if it doesn't exist. func fileLineCountImpl(ctx *BuiltinContext, path string) int { - fullPath := ctx.resolvePath(path) + fullPath, pathErr := ctx.resolvePath(path) + if pathErr != nil { + return -1 + } file, err := ctx.Fs.Open(fullPath) if err != nil { @@ -196,7 +201,10 @@ func fileLineCountImpl(ctx *BuiltinContext, path string) int { // hasPatternImpl returns true if the file contains text matching the regex pattern. func hasPatternImpl(ctx *BuiltinContext, path, pattern string) bool { - fullPath := ctx.resolvePath(path) + fullPath, pathErr := ctx.resolvePath(path) + if pathErr != nil { + return false + } // Compile the regex re, err := regexp.Compile(pattern) @@ -215,7 +223,10 @@ func hasPatternImpl(ctx *BuiltinContext, path, pattern string) bool { // fileImportsImpl extracts imports from a file. // Currently supports Go import statements. func fileImportsImpl(ctx *BuiltinContext, path string) []string { - fullPath := ctx.resolvePath(path) + fullPath, pathErr := ctx.resolvePath(path) + if pathErr != nil { + return nil + } content, err := afero.ReadFile(ctx.Fs, fullPath) if err != nil { @@ -294,7 +305,10 @@ func symbolExistsImpl(ctx *BuiltinContext, path, symbolName string) bool { } // Fallback: Simple text-based search - fullPath := ctx.resolvePath(path) + fullPath, pathErr := ctx.resolvePath(path) + if pathErr != nil { + return false + } content, err := afero.ReadFile(ctx.Fs, fullPath) if err != nil { return false @@ -327,7 +341,10 @@ func symbolExistsImpl(ctx *BuiltinContext, path, symbolName string) bool { // fileExistsImpl checks if a file exists. func fileExistsImpl(ctx *BuiltinContext, path string) bool { - fullPath := ctx.resolvePath(path) + fullPath, pathErr := ctx.resolvePath(path) + if pathErr != nil { + return false + } exists, _ := afero.Exists(ctx.Fs, fullPath) return exists } diff --git a/internal/project/detect_test.go b/internal/project/detect_test.go index ce18798..e22adb3 100644 --- a/internal/project/detect_test.go +++ b/internal/project/detect_test.go @@ -21,7 +21,6 @@ func setupFS(paths []string) afero.Fs { return fs } - func TestDetect_MonorepoRootWithGitOnly(t *testing.T) { // Polyglot monorepo: .git at root, multiple subdirs with manifests, no root manifest // This is the markwise-app scenario. diff --git a/internal/task/models.go b/internal/task/models.go index 41c1270..1924d18 100644 --- a/internal/task/models.go +++ b/internal/task/models.go @@ -169,8 +169,8 @@ type Task struct { ValidationSteps []string `json:"validationSteps"` // CLI commands // AI integration fields - for MCP tool context fetching - Scope string `json:"scope,omitempty"` // e.g., "auth", "api", "vectorsearch" - Keywords []string `json:"keywords,omitempty"` // Extracted from title/description + Scope string `json:"scope,omitempty"` // e.g., "auth", "api", "vectorsearch" + Keywords []string `json:"keywords,omitempty"` // Extracted from title/description SuggestedAskQueries []string `json:"suggestedAskQueries,omitempty"` // Pre-computed queries for ask tool // Session tracking - for AI tool state management diff --git a/internal/ui/bootstrap_tui.go b/internal/ui/bootstrap_tui.go index 777322d..9ff7dfc 100644 --- a/internal/ui/bootstrap_tui.go +++ b/internal/ui/bootstrap_tui.go @@ -243,20 +243,14 @@ func (m BootstrapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { state.Err = msg.Err state.Message = fmt.Sprintf("Error: %v", msg.Err) } else if msg.Output != nil && msg.Output.Error != nil { - // Treat complete git-agent failure as fatal. For other agents, - // preserve warning behavior for non-critical partial issues. - if msg.Name == "git" && len(msg.Output.Findings) == 0 { - state.Status = StatusError - state.Err = msg.Output.Error - state.Result = msg.Output - state.Message = fmt.Sprintf("Error: %v", msg.Output.Error) - } else { - state.Status = StatusDone - state.Err = nil // Clear any intermediate retry errors - state.Result = msg.Output - state.Message = fmt.Sprintf("Warning: %v", msg.Output.Error) - m.Results = append(m.Results, *msg.Output) - } + // Non-fatal: agent completed but with partial issues (e.g., no git + // history, no source files, no dependency files). Show as warning + // and continue. Empty repos and fresh git-init projects are valid. + state.Status = StatusDone + state.Err = nil // Clear any intermediate retry errors + state.Result = msg.Output + state.Message = fmt.Sprintf("Warning: %v", msg.Output.Error) + m.Results = append(m.Results, *msg.Output) } else { // Agent completed successfully - clear any intermediate errors state.Status = StatusDone diff --git a/internal/ui/bootstrap_view.go b/internal/ui/bootstrap_view.go new file mode 100644 index 0000000..d8547d4 --- /dev/null +++ b/internal/ui/bootstrap_view.go @@ -0,0 +1,115 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + agentcore "github.com/josephgoksu/TaskWing/internal/agents/core" +) + +// RenderBootstrapResults displays the bootstrap coverage report using the same +// visual language as tw knowledge (bordered header, dim stats, grouped sections). +func RenderBootstrapResults(report *agentcore.BootstrapReport) { + // Header box - matches knowledge command style + headerBox := lipgloss.NewStyle(). + Bold(true). + Foreground(ColorPrimary). + Padding(0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorSecondary) + + fmt.Println() + fmt.Println(headerBox.Render(fmt.Sprintf("Bootstrap Results (%d findings)", report.TotalFindings))) + + // Stats line - dim, below header, matches knowledge style + var statParts []string + if len(report.FindingCounts) > 0 { + for fType, count := range report.FindingCounts { + statParts = append(statParts, fmt.Sprintf("%d %s", count, fType)) + } + } + if report.Coverage.FilesAnalyzed > 0 { + fileStat := fmt.Sprintf("%d files analyzed", report.Coverage.FilesAnalyzed) + if report.Coverage.FilesSkipped > 0 { + fileStat += fmt.Sprintf(", %d skipped", report.Coverage.FilesSkipped) + } + statParts = append(statParts, fileStat) + } + if len(statParts) > 0 { + fmt.Printf(" %s\n", StyleSubtle.Render(strings.Join(statParts, " "))) + } + fmt.Println() + + // Agent results - grouped with badges like knowledge sections + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorText) + itemStyle := lipgloss.NewStyle().Foreground(ColorText) + indexStyle := lipgloss.NewStyle().Foreground(ColorDim) + skipStyle := lipgloss.NewStyle().Foreground(ColorDim) + + var active, skipped []agentEntry + for name, ar := range report.AgentReports { + if ar.FindingCount > 0 { + active = append(active, agentEntry{name: name, report: ar}) + } else { + skipped = append(skipped, agentEntry{name: name, report: ar}) + } + } + + // Active agents with findings + if len(active) > 0 { + label := fmt.Sprintf("Agents with findings (%d)", len(active)) + fmt.Printf(" %s %s\n", StyleSuccess.Render("✓"), sectionStyle.Render(label)) + for i, a := range active { + findingWord := "findings" + if a.report.FindingCount == 1 { + findingWord = "finding" + } + idx := indexStyle.Render(fmt.Sprintf("%d.", i+1)) + text := fmt.Sprintf("%s: %d %s", a.name, a.report.FindingCount, findingWord) + fmt.Printf(" %s %s\n", idx, itemStyle.Render(text)) + } + fmt.Println() + } + + // Skipped agents + if len(skipped) > 0 { + label := fmt.Sprintf("Skipped (%d)", len(skipped)) + fmt.Printf(" %s %s\n", skipStyle.Render("-"), skipStyle.Render(label)) + for _, a := range skipped { + reason := summarizeSkipReason(a.report.Error) + fmt.Printf(" %s\n", skipStyle.Render(fmt.Sprintf("%s: %s", a.name, reason))) + } + fmt.Println() + } + + fmt.Printf(" %s\n", StyleSubtle.Render("Full report: .taskwing/last-bootstrap-report.json")) +} + +type agentEntry struct { + name string + report agentcore.AgentReport +} + +// summarizeSkipReason returns a brief human-readable reason for skipping. +func summarizeSkipReason(errMsg string) string { + if errMsg == "" { + return "no findings" + } + lower := strings.ToLower(errMsg) + switch { + case strings.Contains(lower, "no source files") || strings.Contains(lower, "no source code"): + return "no source files" + case strings.Contains(lower, "no git history"): + return "no git history" + case strings.Contains(lower, "no dependency"): + return "no dependency files" + case strings.Contains(lower, "chunking failed"): + return "no source files" + default: + if len(errMsg) > 40 { + return errMsg[:40] + "..." + } + return errMsg + } +} diff --git a/internal/ui/context_view.go b/internal/ui/context_view.go index e1b801f..340fe9f 100644 --- a/internal/ui/context_view.go +++ b/internal/ui/context_view.go @@ -79,7 +79,7 @@ func renderContextInternal(query string, scored []knowledge.ScoredNode, answer s func renderScoredNodePanel(index int, s knowledge.ScoredNode, maxScore float32, verbose bool) { // Styles var ( - headerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "6", Dark: "14"}).Bold(true) + headerStyle = lipgloss.NewStyle().Foreground(ColorCyan).Bold(true) metaStyle = lipgloss.NewStyle().Foreground(ColorSecondary) contentStyle = lipgloss.NewStyle().Foreground(ColorText) barFull = lipgloss.NewStyle().Foreground(ColorSuccess) @@ -233,10 +233,10 @@ func renderContextWithSymbolsInternal(query string, scored []knowledge.ScoredNod func renderSymbolPanel(index int, sym app.SymbolResponse, verbose bool) { // Styles var ( - headerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "6", Dark: "14"}).Bold(true) + headerStyle = lipgloss.NewStyle().Foreground(ColorCyan).Bold(true) metaStyle = lipgloss.NewStyle().Foreground(ColorSecondary) - locationStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "39"}) - panelBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.AdaptiveColor{Light: "55", Dark: "63"}).Padding(0, 1).MarginTop(1) + locationStyle = lipgloss.NewStyle().Foreground(ColorBlue) + panelBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(ColorPurple).Padding(0, 1).MarginTop(1) ) icon := symbolKindIcon(sym.Kind) @@ -310,31 +310,34 @@ func getContentWithoutSummary(content, summary string) string { func RenderAskResult(result *app.AskResult, verbose bool) { sectionStyle := lipgloss.NewStyle().Foreground(ColorPurple).Bold(true) - // Header with query in a styled box - headerBox := lipgloss.NewStyle(). - Bold(true). - Foreground(ColorPrimary). - Padding(0, 1). - Border(lipgloss.RoundedBorder()). - BorderForeground(ColorSecondary) - fmt.Println() + + // Compact header: just the query, no box when there's an answer if result.Answer != "" { - fmt.Println(headerBox.Render(fmt.Sprintf("Q: %s", result.Query))) + fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(ColorPrimary).Render( + fmt.Sprintf("Q: %s", result.Query))) } else { + headerBox := lipgloss.NewStyle(). + Bold(true). + Foreground(ColorPrimary). + Padding(0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorSecondary) fmt.Println(headerBox.Render(fmt.Sprintf("Search: %s", result.Query))) } - // Pipeline & rewrite as dim metadata - var metaParts []string - metaParts = append(metaParts, result.Pipeline) - if result.RewrittenQuery != "" { - metaParts = append(metaParts, fmt.Sprintf("rewritten: %s", result.RewrittenQuery)) - } - if result.Total > 0 || result.TotalSymbols > 0 { - metaParts = append(metaParts, fmt.Sprintf("%d knowledge, %d symbols", result.Total, result.TotalSymbols)) + // Show result count as dim metadata (skip internal pipeline details) + if verbose { + var metaParts []string + metaParts = append(metaParts, result.Pipeline) + if result.RewrittenQuery != "" { + metaParts = append(metaParts, fmt.Sprintf("rewritten: %s", result.RewrittenQuery)) + } + if result.Total > 0 || result.TotalSymbols > 0 { + metaParts = append(metaParts, fmt.Sprintf("%d knowledge, %d symbols", result.Total, result.TotalSymbols)) + } + fmt.Println(StyleAskMeta.Render(" " + strings.Join(metaParts, " | "))) } - fmt.Println(StyleAskMeta.Render(" " + strings.Join(metaParts, " | "))) // Warning if result.Warning != "" { @@ -342,15 +345,25 @@ func RenderAskResult(result *app.AskResult, verbose bool) { fmt.Println(RenderWarningPanel("Warning", result.Warning)) } - // Answer box with accent border + // Answer: render markdown with terminal formatting, adaptive width if result.Answer != "" { fmt.Println() + termWidth := GetTerminalWidth() + answerWidth := termWidth - 6 // 4 for border + 2 for padding + if answerWidth < 60 { + answerWidth = 60 + } + if answerWidth > 120 { + answerWidth = 120 + } + + formatted := formatMarkdownForTerminal(result.Answer) answerBox := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(ColorBlue). - Padding(1, 2). - Width(80) - fmt.Println(answerBox.Render(result.Answer)) + Padding(0, 2). + Width(answerWidth) + fmt.Println(answerBox.Render(formatted)) } // Sources as compact citations @@ -393,6 +406,75 @@ func RenderAskResult(result *app.AskResult, verbose bool) { fmt.Println() } +// formatMarkdownForTerminal applies basic terminal formatting to markdown text. +// Converts headings to bold+colored, **bold** to bold, --- to dim separators, +// and preserves list indentation. This avoids a full glamour dependency. +func formatMarkdownForTerminal(md string) string { + headingStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorPrimary) + subheadingStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorText) + separatorStyle := lipgloss.NewStyle().Foreground(ColorDim) + boldStyle := lipgloss.NewStyle().Bold(true) + + lines := strings.Split(md, "\n") + var out []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Horizontal rules + if trimmed == "---" || trimmed == "***" || trimmed == "___" { + out = append(out, separatorStyle.Render(strings.Repeat("-", 50))) + continue + } + + // Headings: ## and ### + if strings.HasPrefix(trimmed, "### ") { + text := strings.TrimPrefix(trimmed, "### ") + text = stripMarkdownBold(text) + out = append(out, "") + out = append(out, subheadingStyle.Render(text)) + continue + } + if strings.HasPrefix(trimmed, "## ") { + text := strings.TrimPrefix(trimmed, "## ") + text = stripMarkdownBold(text) + out = append(out, "") + out = append(out, headingStyle.Render(text)) + continue + } + + // Inline **bold** replacement + processed := replaceMarkdownBold(line, boldStyle) + out = append(out, processed) + } + + return strings.Join(out, "\n") +} + +// stripMarkdownBold removes **markers** from text. +func stripMarkdownBold(s string) string { + return strings.ReplaceAll(s, "**", "") +} + +// replaceMarkdownBold converts **text** to bold-styled text. +func replaceMarkdownBold(line string, style lipgloss.Style) string { + result := line + for { + start := strings.Index(result, "**") + if start == -1 { + break + } + end := strings.Index(result[start+2:], "**") + if end == -1 { + break + } + end += start + 2 + boldText := result[start+2 : end] + result = result[:start] + style.Render(boldText) + result[end+2:] + } + return result +} + // renderCitation renders a knowledge source as a compact citation line. func renderCitation(index int, s knowledge.ScoredNode, maxScore float32) { summary := s.Node.Summary diff --git a/internal/ui/drift.go b/internal/ui/drift.go index c07a5da..b5ff1b1 100644 --- a/internal/ui/drift.go +++ b/internal/ui/drift.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/charmbracelet/lipgloss" "github.com/josephgoksu/TaskWing/internal/app" ) @@ -17,16 +18,40 @@ func RenderDriftReport(report *app.DriftReport, verbose bool) { // No rules found if report.RulesChecked == 0 { - fmt.Println("📋 No architectural rules found in knowledge base.") - fmt.Println(" Run 'taskwing bootstrap' to extract rules from your codebase,") - fmt.Println(" or refresh rules with 'taskwing bootstrap --force'") + fmt.Println("No architectural rules found in knowledge base.") + fmt.Println(" Run 'tw bootstrap' to extract rules, or 'tw bootstrap --force' to refresh.") return } + // Header box - consistent with knowledge/bootstrap + headerBox := lipgloss.NewStyle(). + Bold(true). + Foreground(ColorPrimary). + Padding(0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorSecondary) + + summary := fmt.Sprintf("Drift Analysis (%d rules checked)", report.RulesChecked) + fmt.Println() + fmt.Println(headerBox.Render(summary)) + + // Stats line + statParts := []string{ + fmt.Sprintf("%d passed", len(report.Passed)), + } + if len(report.Violations) > 0 { + statParts = append(statParts, fmt.Sprintf("%d violations", len(report.Violations))) + } + if len(report.Warnings) > 0 { + statParts = append(statParts, fmt.Sprintf("%d warnings", len(report.Warnings))) + } + fmt.Printf(" %s\n", StyleSubtle.Render(strings.Join(statParts, " "))) + fmt.Println() + // Violations if len(report.Violations) > 0 { - fmt.Printf("❌ %s (%d)\n", StyleBold("VIOLATIONS"), len(report.Violations)) - fmt.Println("────────────────────────") + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorError) + fmt.Printf(" %s\n", sectionStyle.Render(fmt.Sprintf("Violations (%d)", len(report.Violations)))) fmt.Println() // Group by rule @@ -54,13 +79,13 @@ func RenderDriftReport(report *app.DriftReport, verbose bool) { // Warnings if len(report.Warnings) > 0 { - fmt.Printf("⚠️ %s (%d)\n", StyleBold("WARNINGS"), len(report.Warnings)) - fmt.Println("────────────────────────") + warnStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorWarning) + fmt.Printf(" %s\n", warnStyle.Render(fmt.Sprintf("Warnings (%d)", len(report.Warnings)))) fmt.Println() for i, w := range report.Warnings { if i >= 3 && !verbose { - fmt.Printf(" ... and %d more (use --verbose to see all)\n", len(report.Warnings)-3) + fmt.Printf(" ... and %d more (use --verbose to see all)\n", len(report.Warnings)-3) break } renderViolation(w, i+1) @@ -70,39 +95,17 @@ func RenderDriftReport(report *app.DriftReport, verbose bool) { // Passed rules if len(report.Passed) > 0 { - fmt.Printf("✅ %s (%d)\n", StyleBold("PASSED"), len(report.Passed)) - fmt.Println("────────────────────────") + passStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorSuccess) + fmt.Printf(" %s\n", passStyle.Render(fmt.Sprintf("Passed (%d)", len(report.Passed)))) for _, name := range report.Passed { - fmt.Printf(" ✓ %s\n", name) + fmt.Printf(" %s\n", StyleSubtle.Render(name)) } fmt.Println() } - // Summary - fmt.Println("────────────────────────") - fmt.Printf("📊 %s: ", StyleBold("Summary")) - - parts := []string{} - if report.Summary.Violations > 0 { - parts = append(parts, fmt.Sprintf("%d violations", report.Summary.Violations)) - } - if report.Summary.Warnings > 0 { - parts = append(parts, fmt.Sprintf("%d warnings", report.Summary.Warnings)) - } - if report.Summary.Passed > 0 { - parts = append(parts, fmt.Sprintf("%d passed", report.Summary.Passed)) - } - - if len(parts) == 0 { - fmt.Println("no rules checked") - } else { - fmt.Println(strings.Join(parts, ", ")) - } - - // Hint for fixes + // Actionable hint if report.Summary.Violations > 0 { - fmt.Println() - fmt.Println("💡 Review violations and update code to match documented architecture.") + fmt.Printf(" %s\n", StyleSubtle.Render("Review violations and update code to match documented architecture.")) } } diff --git a/internal/ui/explain.go b/internal/ui/explain.go index 1ff3b0d..4162356 100644 --- a/internal/ui/explain.go +++ b/internal/ui/explain.go @@ -11,19 +11,33 @@ import ( // RenderExplainResult renders a deep explanation to the terminal. func RenderExplainResult(result *app.ExplainResult, verbose bool) { - // Symbol header - fmt.Printf("\n%s Symbol: %s (%s)\n", StyleBold("🔍"), result.Symbol.Name, result.Symbol.Kind) - fmt.Printf(" Location: %s\n", result.Symbol.Location) + // Header box - consistent with knowledge/bootstrap/ask + headerBox := lipgloss.NewStyle(). + Bold(true). + Foreground(ColorPrimary). + Padding(0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorSecondary) + + fmt.Println() + fmt.Println(headerBox.Render(fmt.Sprintf("%s (%s)", result.Symbol.Name, result.Symbol.Kind))) + + // Stats line + var metaParts []string + metaParts = append(metaParts, result.Symbol.Location) if result.Symbol.Signature != "" { - fmt.Printf(" Signature: %s\n", result.Symbol.Signature) + metaParts = append(metaParts, result.Symbol.Signature) } + fmt.Printf(" %s\n", StyleSubtle.Render(strings.Join(metaParts, " "))) + if verbose && result.Symbol.DocComment != "" { - fmt.Printf(" Doc: %s\n", truncate(result.Symbol.DocComment, 100)) + fmt.Printf(" %s\n", StyleSubtle.Render(truncate(result.Symbol.DocComment, 100))) } - // Call graph - fmt.Printf("\n%s System Context\n", StyleBold("📊")) - fmt.Println("───────────────") + // Call graph section + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorText) + fmt.Println() + fmt.Printf(" %s\n", sectionStyle.Render("System Context")) // Callers fmt.Printf("\n⬆️ Called By (%d):\n", len(result.Callers)) diff --git a/internal/ui/list_view.go b/internal/ui/list_view.go index 8a85960..3f62465 100644 --- a/internal/ui/list_view.go +++ b/internal/ui/list_view.go @@ -5,22 +5,23 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/josephgoksu/TaskWing/internal/freshness" "github.com/josephgoksu/TaskWing/internal/memory" "github.com/josephgoksu/TaskWing/internal/utils" ) // RenderNodeList renders a list of knowledge nodes to stdout in compact mode. -// For verbose output with full metadata, use RenderNodeListVerbose. -func RenderNodeList(nodes []memory.Node) { - renderNodeListInternal(nodes, false) +// basePath is the project root for freshness checks (empty to skip). +func RenderNodeList(nodes []memory.Node, basePath string) { + renderNodeListInternal(nodes, false, basePath) } // RenderNodeListVerbose renders nodes with full metadata (ID, dates, type). -func RenderNodeListVerbose(nodes []memory.Node) { - renderNodeListInternal(nodes, true) +func RenderNodeListVerbose(nodes []memory.Node, basePath string) { + renderNodeListInternal(nodes, true, basePath) } -func renderNodeListInternal(nodes []memory.Node, verbose bool) { +func renderNodeListInternal(nodes []memory.Node, verbose bool, basePath string) { // Group by type byType := make(map[string][]memory.Node) for _, n := range nodes { @@ -33,18 +34,36 @@ func renderNodeListInternal(nodes []memory.Node, verbose bool) { // Calculate stats - use centralized type list typeOrder := append(memory.AllNodeTypes(), "unknown") - var stats []string + totalCount := 0 + for _, t := range typeOrder { + totalCount += len(byType[t]) + } + + // Check if workspace column is useful (more than one distinct workspace) + showWorkspace := hasMultipleWorkspaces(nodes) + + // Render header with readable type counts + renderHeader(byType, typeOrder, totalCount) + if verbose { + renderVerboseTable(byType, typeOrder) + } else { + renderGroupedList(byType, typeOrder, showWorkspace, basePath) + } +} + +// renderHeader displays the summary box with spelled-out type names. +func renderHeader(byType map[string][]memory.Node, typeOrder []string, total int) { + var statParts []string for _, t := range typeOrder { count := len(byType[t]) if count > 0 { - totalCount += count - stats = append(stats, fmt.Sprintf("%s %d", TypeIcon(t), count)) + label := typePlural(t, count) + statParts = append(statParts, fmt.Sprintf("%d %s", count, label)) } } - // Render Header headerBox := lipgloss.NewStyle(). Bold(true). Foreground(ColorPrimary). @@ -52,88 +71,86 @@ func renderNodeListInternal(nodes []memory.Node, verbose bool) { Border(lipgloss.RoundedBorder()). BorderForeground(ColorSecondary) - fmt.Println(headerBox.Render(fmt.Sprintf("Knowledge: %d nodes (%s)", totalCount, strings.Join(stats, " | ")))) - fmt.Println() + fmt.Println(headerBox.Render(fmt.Sprintf("Project Knowledge (%d nodes)", total))) - if verbose { - renderVerboseTable(byType, typeOrder) - } else { - renderStyledTable(byType, typeOrder) + // Stats line below the box - readable breakdown + if len(statParts) > 0 { + fmt.Printf(" %s\n", StyleSubtle.Render(strings.Join(statParts, " "))) } + fmt.Println() } -// renderStyledTable renders all nodes in a single styled table with category badges. -func renderStyledTable(byType map[string][]memory.Node, typeOrder []string) { - // Collect all nodes in order - type nodeRow struct { - node memory.Node - } - var rows []nodeRow - - for _, t := range typeOrder { - for _, n := range byType[t] { - rows = append(rows, nodeRow{node: n}) - } +// renderGroupedList renders nodes grouped by type with section headers. +// Each section shows a colored badge + count, then a simple indented list. +func renderGroupedList(byType map[string][]memory.Node, typeOrder []string, showWorkspace bool, basePath string) { + termWidth := GetTerminalWidth() + // 6 = 4 indent + 2 safety margin + maxSummaryWidth := termWidth - 6 + if showWorkspace { + maxSummaryWidth -= 14 // workspace column } - - if len(rows) == 0 { - return + if maxSummaryWidth < 40 { + maxSummaryWidth = 40 } - // Column widths - const ( - colBadge = 15 - colSummary = 50 - colWorkspace = 12 - ) + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorText) + itemStyle := lipgloss.NewStyle().Foreground(ColorText) + indexStyle := lipgloss.NewStyle().Foreground(ColorDim) + staleStyle := lipgloss.NewStyle().Foreground(ColorWarning) + wsStyle := lipgloss.NewStyle().Foreground(ColorDim) - // Header - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorPrimary).Underline(true) - dimSep := StyleSubtle.Render(" ") + for _, t := range typeOrder { + groupNodes := byType[t] + if len(groupNodes) == 0 { + continue + } - fmt.Printf(" %s%s%s%s%s\n", - headerStyle.Render(padRight("Category", colBadge)), - dimSep, - headerStyle.Render(padRight("Summary", colSummary)), - dimSep, - headerStyle.Render(padRight("Workspace", colWorkspace)), - ) + // Section header: badge + "Decisions (6)" + badge := CategoryBadge(t) + label := fmt.Sprintf("%s (%d)", utils.ToTitle(typePlural(t, len(groupNodes))), len(groupNodes)) + fmt.Printf(" %s %s\n", badge, sectionStyle.Render(label)) - // Separator - fmt.Printf(" %s\n", StyleSubtle.Render(strings.Repeat("─", colBadge+colSummary+colWorkspace+6))) + // Items with numbered indices for scanability + for i, n := range groupNodes { + summary := n.Summary + if summary == "" { + summary = utils.Truncate(n.Text(), maxSummaryWidth-4) + } - // Rows with alternating colors - for i, r := range rows { - summary := r.node.Summary - if summary == "" { - summary = utils.Truncate(r.node.Text(), colSummary) - } - if len(summary) > colSummary { - summary = summary[:colSummary-1] + "…" - } + // Check freshness if basePath is available and node has evidence + staleTag := "" + if basePath != "" && n.Evidence != "" { + result := freshness.Check(basePath, n.Evidence, n.CreatedAt) + if result.Status == freshness.StatusStale { + staleTag = staleStyle.Render(" [stale]") + } else if result.Status == freshness.StatusMissing { + staleTag = staleStyle.Render(" [missing]") + } + } - workspace := r.node.Workspace - if workspace == "" { - workspace = "root" - } + // Account for stale tag width in truncation + availWidth := maxSummaryWidth - 4 + if staleTag != "" { + availWidth -= 9 // " [stale]" or " [missing]" + } + if lipgloss.Width(summary) > availWidth { + summary = truncateToWidth(summary, availWidth) + } - badge := CategoryBadge(r.node.Type) + idx := indexStyle.Render(fmt.Sprintf("%d.", i+1)) - // Alternating row style - var rowStyle lipgloss.Style - if i%2 == 0 { - rowStyle = StyleTableRowEven - } else { - rowStyle = StyleTableRowOdd + if showWorkspace { + ws := n.Workspace + if ws == "" { + ws = "root" + } + fmt.Printf(" %s %s%s %s\n", idx, itemStyle.Render(padRight(summary, availWidth)), staleTag, wsStyle.Render(ws)) + } else { + fmt.Printf(" %s %s%s\n", idx, itemStyle.Render(summary), staleTag) + } } - - fmt.Printf(" %s %s %s\n", - badge+strings.Repeat(" ", max(0, colBadge-lipgloss.Width(badge))), - rowStyle.Render(padRight(summary, colSummary)), - StyleSubtle.Render(padRight(workspace, colWorkspace)), - ) + fmt.Println() } - fmt.Println() } // renderVerboseTable renders nodes as a table with full metadata. @@ -144,7 +161,8 @@ func renderVerboseTable(byType map[string][]memory.Node, typeOrder []string) { continue } - fmt.Printf(" %s %s\n", CategoryBadge(t), StyleHeader.Render(fmt.Sprintf("%s %ss", TypeIcon(t), utils.ToTitle(t)))) + label := fmt.Sprintf("%s (%d)", utils.ToTitle(typePlural(t, len(groupNodes))), len(groupNodes)) + fmt.Printf(" %s %s\n", CategoryBadge(t), StyleHeader.Render(label)) table := &Table{ Headers: []string{"ID", "Summary", "Workspace", "Created", "Agent"}, @@ -181,6 +199,69 @@ func renderVerboseTable(byType map[string][]memory.Node, typeOrder []string) { } } +// hasMultipleWorkspaces returns true if nodes span more than one workspace. +// When false, the workspace column can be hidden to save horizontal space. +func hasMultipleWorkspaces(nodes []memory.Node) bool { + if len(nodes) == 0 { + return false + } + first := nodes[0].Workspace + if first == "" { + first = "root" + } + for _, n := range nodes[1:] { + ws := n.Workspace + if ws == "" { + ws = "root" + } + if ws != first { + return true + } + } + return false +} + +// typePlural returns the human-readable plural label for a node type. +func typePlural(t string, count int) string { + if count == 1 { + return typeSingular(t) + } + switch t { + case memory.NodeTypeDecision: + return "decisions" + case memory.NodeTypeFeature: + return "features" + case memory.NodeTypeConstraint: + return "constraints" + case memory.NodeTypePattern: + return "patterns" + case memory.NodeTypePlan: + return "plans" + case memory.NodeTypeNote: + return "notes" + case memory.NodeTypeMetadata: + return "metadata" + case memory.NodeTypeDocumentation: + return "docs" + default: + return t + "s" + } +} + +// typeSingular returns the human-readable singular label for a node type. +func typeSingular(t string) string { + switch t { + case memory.NodeTypeDocumentation: + return "doc" + case memory.NodeTypeMetadata: + return "metadata" + default: + return t + } +} + +// TypeIcon returns a short abbreviation for the header stats. +// Used by bootstrap output and other compact displays. func TypeIcon(t string) string { switch t { case memory.NodeTypeDecision: diff --git a/internal/ui/plan_tui.go b/internal/ui/plan_tui.go deleted file mode 100644 index d9ebc05..0000000 --- a/internal/ui/plan_tui.go +++ /dev/null @@ -1,1080 +0,0 @@ -package ui - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/cloudwego/eino/callbacks" - "github.com/josephgoksu/TaskWing/internal/agents/core" - "github.com/josephgoksu/TaskWing/internal/agents/impl" - "github.com/josephgoksu/TaskWing/internal/app" - "github.com/josephgoksu/TaskWing/internal/knowledge" - "github.com/josephgoksu/TaskWing/internal/memory" -) - -type PlanState int - -const ( - StateUninitialized PlanState = iota - StateInitializing - StateClarifyingThinking - StateClarifyingInput - StateAnsweringQuestion // One-by-one Q&A flow - StatePlanningThinking - StatePlanningPulse // Streaming logic - StateSuccess - StateError -) - -// Layout constants -const ( - DefaultViewportWidth = 80 - DefaultViewportHeight = 15 - MinViewportHeight = 10 - DefaultTextareaWidth = 60 - DefaultTextareaHeight = 6 - MaxTextareaHeight = 15 - HeaderFooterHeight = 12 // Space for header + input + footer - MaxMsgWidth = 65 // Max width for wrapped messages - LLMTimeoutSeconds = 60 // Timeout for LLM operations -) - -type PlanModel struct { - // State - State PlanState - PreviousState PlanState // For returning from overlays/cancellation - Err error - InitialGoal string - ClarifySessionID string - GoalSummary string // Concise one-liner for UI display (<100 chars) - EnrichedGoal string // Full technical specification for task generation - PlanID string - PlanSummary string - ThinkingStatus string // Dynamic status message for spinner - - // Data - Msgs []string // Visible chat log for viewport - ClarifyTurns int - KGContext string // Fetched knowledge graph context - MemoryBasePath string // Path to memory directory for ARCHITECTURE.md injection - - // Interactive Q&A State - PendingQuestions []string - CurrentQIdx int - CollectedAnswers []string - AnswerHistory [][]string // For undo: history of all answer states - AutoAnswering bool // Is auto-answer generating? - - // Cancellation & Confirmation - CancelFunc context.CancelFunc // To cancel LLM operations - ShowQuitConfirm bool // Show quit confirmation dialog - ShowHelp bool // Show help overlay - HasUnsavedWork bool // Track if user has entered any content - LLMStartTime time.Time // Track LLM operation start for timeout - - // Input/Output - Input core.Input - GenerateResult *app.GenerateResult - Stream *core.StreamingOutput // Channel for streaming events - - // Components - Spinner spinner.Model - TextInput textarea.Model - Viewport viewport.Model - - // Dependencies - Ctx context.Context - PlanApp *app.PlanApp - KnowledgeService *knowledge.Service - Repo *memory.Repository -} - -type MsgClarificationResult struct { - Output *core.Output - ContextUsed string - Err error -} - -// MsgClarifyResult wraps app.ClarifyResult for unified flow -type MsgClarifyResult struct { - Result *app.ClarifyResult - Err error -} - -type MsgContextFound struct { - Context string - Strategy string // The research strategy explanation - Err error -} - -type MsgAutoAnswerResult struct { - Answer string - Err error -} - -// MsgSingleAutoAnswerResult is for auto-answering one question at a time -type MsgSingleAutoAnswerResult struct { - Answer string - QuestionIdx int - Err error -} - -type MsgGenerateResult struct { - Result *app.GenerateResult - Err error -} - -// MsgLLMTimeout signals LLM operation timed out -type MsgLLMTimeout struct { - Operation string // What operation timed out -} - -// MsgLLMCancelled signals LLM operation was cancelled by user -type MsgLLMCancelled struct { - Operation string -} - -// MsgCheckTimeout is sent periodically to check for LLM timeouts -type MsgCheckTimeout struct{} - -func NewPlanModel( - ctx context.Context, - goal string, - planApp *app.PlanApp, - ks *knowledge.Service, - repo *memory.Repository, - stream *core.StreamingOutput, - memoryBasePath string, -) PlanModel { - // Styled TextArea - ti := textarea.New() - ti.Placeholder = "Edit the specification or [Ctrl+S] to approve..." - ti.Focus() - ti.CharLimit = 0 // Unlimited - ti.SetWidth(DefaultTextareaWidth) - ti.SetHeight(DefaultTextareaHeight) - ti.ShowLineNumbers = false - - // Spinner - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = StylePrimary - - // Viewport for history - vp := viewport.New(DefaultViewportWidth, DefaultViewportHeight) - - return PlanModel{ - State: StateInitializing, - InitialGoal: goal, - EnrichedGoal: goal, // Start same - ThinkingStatus: "Strategizing research & analyzing memory...", - PlanApp: planApp, - KnowledgeService: ks, - Repo: repo, - Stream: stream, - MemoryBasePath: memoryBasePath, - Ctx: ctx, - Spinner: s, - TextInput: ti, - Viewport: vp, - Msgs: []string{StyleSubtle.Render("◆ Analyzing project memory...")}, - } -} - -func (m PlanModel) Init() tea.Cmd { - return tea.Batch( - m.Spinner.Tick, - m.searchContext, - ) -} - -// searchContext fetches relevant KG nodes -func (m PlanModel) searchContext() tea.Msg { - // Use shared logic for consistency with Eval system - // Pass MemoryBasePath to enable ARCHITECTURE.md injection - result, err := impl.RetrieveContext(m.Ctx, m.KnowledgeService, m.InitialGoal, m.MemoryBasePath) - if err != nil { - // Even if error (unlikely as RetrieveContext handles fallbacks internally), return it - return MsgContextFound{Context: "", Strategy: "", Err: err} - } - - return MsgContextFound{Context: result.Context, Strategy: result.Strategy} -} - -// runClarify runs clarification via PlanApp.Clarify for unified logic. -// This ensures TUI and MCP/CLI use the exact same code path. -func runClarify(ctx context.Context, planApp *app.PlanApp, opts app.ClarifyOptions) tea.Cmd { - return func() tea.Msg { - result, err := planApp.Clarify(ctx, opts) - return MsgClarifyResult{Result: result, Err: err} - } -} - -// RunGenerate runs plan generation via PlanApp.Generate. -// It wraps the context with streaming callbacks so the TUI can visualize progress. -func runGenerate(ctx context.Context, appLayer *app.PlanApp, goal, clarifySessionID, enrichedGoal string, stream *core.StreamingOutput) tea.Cmd { - return func() tea.Msg { - // Callback Handler - // We use "planning" as component name to match expected stream events - handler := core.CreateStreamingCallbackHandler("planning", stream) - runCtx := callbacks.InitCallbacks(ctx, &callbacks.RunInfo{Name: "planning"}, handler.Build()) - - // Call PlanApp.Generate - // This will: 1. Run agent (streaming events will fire), 2. Validate tasks, 3. Save to DB - res, err := appLayer.Generate(runCtx, app.GenerateOptions{ - Goal: goal, - ClarifySessionID: clarifySessionID, - EnrichedGoal: enrichedGoal, - Save: true, - }) - - return MsgGenerateResult{Result: res, Err: err} - } -} - -// listenForStream listens for Pulse events -func listenForStream(stream *core.StreamingOutput) tea.Cmd { - return func() tea.Msg { - event, ok := <-stream.Events - if !ok { - return nil - } - return event - } -} - -// runAutoAnswer triggers PlanApp.Clarify with auto-answer mode -func runAutoAnswer(ctx context.Context, planApp *app.PlanApp, goal, clarifySessionID string) tea.Cmd { - return func() tea.Msg { - timeoutCtx, cancel := context.WithTimeout(ctx, LLMTimeoutSeconds*time.Second) - defer cancel() - - result, err := planApp.Clarify(timeoutCtx, app.ClarifyOptions{ - Goal: goal, - ClarifySessionID: clarifySessionID, - AutoAnswer: true, // Let PlanApp handle auto-answering - MaxRounds: 5, - }) - - if err != nil && timeoutCtx.Err() == context.DeadlineExceeded { - return MsgLLMTimeout{Operation: "auto-answer"} - } - if err != nil && timeoutCtx.Err() == context.Canceled { - return MsgLLMCancelled{Operation: "auto-answer"} - } - - // Return the enriched goal as answer - if result != nil && result.EnrichedGoal != "" { - return MsgAutoAnswerResult{Answer: result.EnrichedGoal, Err: err} - } - return MsgAutoAnswerResult{Answer: "", Err: err} - } -} - -// runSingleAutoAnswer auto-answers a single question using PlanApp -// For single question auto-answer, we format it as history and call Clarify -func runSingleAutoAnswer(ctx context.Context, planApp *app.PlanApp, goal, clarifySessionID, question string, qIdx int) tea.Cmd { - return func() tea.Msg { - timeoutCtx, cancel := context.WithTimeout(ctx, LLMTimeoutSeconds*time.Second) - defer cancel() - - result, err := planApp.Clarify(timeoutCtx, app.ClarifyOptions{ - Goal: goal, - ClarifySessionID: clarifySessionID, - AutoAnswer: true, - MaxRounds: 5, - }) - - if err != nil && timeoutCtx.Err() == context.DeadlineExceeded { - return MsgLLMTimeout{Operation: "auto-answer question"} - } - if err != nil && timeoutCtx.Err() == context.Canceled { - return MsgLLMCancelled{Operation: "auto-answer question"} - } - - // Extract answer from enriched goal - answer := "" - if result != nil && result.EnrichedGoal != "" { - answer = result.EnrichedGoal - } - return MsgSingleAutoAnswerResult{Answer: answer, QuestionIdx: qIdx, Err: err} - } -} - -// checkTimeout sends a timeout check message after a delay -func checkTimeout() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { - return MsgCheckTimeout{} - }) -} - -func (m PlanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.Viewport.Width = msg.Width - 4 // Account for borders - m.Viewport.Height = msg.Height - HeaderFooterHeight - if m.Viewport.Height < MinViewportHeight { - m.Viewport.Height = MinViewportHeight - } - m.TextInput.SetWidth(msg.Width - 6) - return m, nil - - case tea.KeyMsg: - // === GLOBAL KEY HANDLERS === - - // Ctrl+C always quits immediately - if msg.Type == tea.KeyCtrlC { - return m, tea.Quit - } - - // ? toggles help overlay (P2) - if msg.String() == "?" && !m.ShowQuitConfirm { - m.ShowHelp = !m.ShowHelp - return m, nil - } - - // If help overlay is showing, any key dismisses it - if m.ShowHelp { - m.ShowHelp = false - return m, nil - } - - // If quit confirmation is showing, handle y/n - if m.ShowQuitConfirm { - switch msg.String() { - case "y", "Y": - return m, tea.Quit - case "n", "N", "escape": - m.ShowQuitConfirm = false - return m, nil - } - return m, nil // Ignore other keys - } - - // Esc: Cancel LLM operation or show quit confirmation (P0) - if msg.Type == tea.KeyEscape { - if m.AutoAnswering { - // Cancel ongoing LLM operation - if m.CancelFunc != nil { - m.CancelFunc() - } - m.AutoAnswering = false - m.addMsg("SYSTEM", "Operation cancelled.") - return m, nil - } - // If user has entered content, show quit confirmation - if m.HasUnsavedWork { - m.ShowQuitConfirm = true - return m, nil - } - // Otherwise, go back one state or quit - switch m.State { - case StateAnsweringQuestion: - if m.CurrentQIdx > 0 { - // Go back to previous question - m.CurrentQIdx-- - m.TextInput.SetValue(m.CollectedAnswers[m.CurrentQIdx]) - m.addMsg("SYSTEM", fmt.Sprintf("Back to question %d/%d", m.CurrentQIdx+1, len(m.PendingQuestions))) - } - return m, nil - case StateClarifyingInput: - m.ShowQuitConfirm = true - return m, nil - default: - m.ShowQuitConfirm = true - return m, nil - } - } - - // 'q' to quit from non-input states (with confirmation if work done) - if msg.String() == "q" && m.State != StateClarifyingInput && m.State != StateAnsweringQuestion { - if m.HasUnsavedWork { - m.ShowQuitConfirm = true - return m, nil - } - return m, tea.Quit - } - - // === STATE-SPECIFIC KEY HANDLERS === - - // Vim keys for viewport scrolling (P2) - only in non-input states - if m.State != StateClarifyingInput && m.State != StateAnsweringQuestion { - switch msg.String() { - case "j": - m.Viewport.ScrollDown(1) - return m, nil - case "k": - m.Viewport.ScrollUp(1) - return m, nil - case "g": - m.Viewport.GotoTop() - return m, nil - case "G": - m.Viewport.GotoBottom() - return m, nil - } - } - - // Handle one-by-one question answering - if m.State == StateAnsweringQuestion { - if m.AutoAnswering { - // Only Esc can interrupt (handled above) - return m, nil - } - - // Tab: auto-answer current question - if msg.Type == tea.KeyTab { - m.AutoAnswering = true - m.LLMStartTime = time.Now() - m.HasUnsavedWork = true - currentQ := m.PendingQuestions[m.CurrentQIdx] - m.addMsg("SYSTEM", "Auto-generating answer... (Esc to cancel)") - cmds = append(cmds, runSingleAutoAnswer(m.Ctx, m.PlanApp, m.InitialGoal, m.ClarifySessionID, currentQ, m.CurrentQIdx)) - cmds = append(cmds, checkTimeout()) - return m, tea.Batch(cmds...) - } - - // Ctrl+N or →: Skip to next question (P1) - if msg.Type == tea.KeyCtrlN || msg.String() == "right" { - // Save current answer (even if empty) and move forward - m.CollectedAnswers[m.CurrentQIdx] = strings.TrimSpace(m.TextInput.Value()) - if m.CurrentQIdx < len(m.PendingQuestions)-1 { - m.CurrentQIdx++ - m.TextInput.SetValue(m.CollectedAnswers[m.CurrentQIdx]) - m.TextInput.Focus() - } - return m, nil - } - - // Ctrl+P or ←: Go to previous question (P1) - if msg.Type == tea.KeyCtrlP || msg.String() == "left" { - // Save current answer and move back - m.CollectedAnswers[m.CurrentQIdx] = strings.TrimSpace(m.TextInput.Value()) - if m.CurrentQIdx > 0 { - m.CurrentQIdx-- - m.TextInput.SetValue(m.CollectedAnswers[m.CurrentQIdx]) - m.TextInput.Focus() - } - return m, nil - } - - // Ctrl+Z: Undo last answer (P2) - if msg.Type == tea.KeyCtrlZ { - if len(m.AnswerHistory) > 0 { - // Restore previous state - lastState := m.AnswerHistory[len(m.AnswerHistory)-1] - m.AnswerHistory = m.AnswerHistory[:len(m.AnswerHistory)-1] - copy(m.CollectedAnswers, lastState) - m.TextInput.SetValue(m.CollectedAnswers[m.CurrentQIdx]) - m.addMsg("SYSTEM", "Undo successful.") - } - return m, nil - } - - // Enter: submit answer and move to next question - if msg.Type == tea.KeyEnter { - answer := strings.TrimSpace(m.TextInput.Value()) - if answer == "" { - return m, nil // Ignore empty - } - - // Save for undo (P2) - historyCopy := make([]string, len(m.CollectedAnswers)) - copy(historyCopy, m.CollectedAnswers) - m.AnswerHistory = append(m.AnswerHistory, historyCopy) - - // Store answer - m.CollectedAnswers[m.CurrentQIdx] = answer - m.HasUnsavedWork = true - m.addMsg("USER", answer) - - // Move to next question or finish - m.CurrentQIdx++ - m.TextInput.Reset() - - if m.CurrentQIdx >= len(m.PendingQuestions) { - answers := buildClarifyAnswers(m.PendingQuestions, m.CollectedAnswers) - m.State = StateClarifyingThinking - m.ThinkingStatus = "Applying answers..." - m.Msgs = append(m.Msgs, StyleSubtle.Render("Updating specification...")) - cmds = append(cmds, runClarify(m.Ctx, m.PlanApp, app.ClarifyOptions{ - Goal: m.InitialGoal, - ClarifySessionID: m.ClarifySessionID, - Answers: answers, - MaxRounds: 5, - })) - return m, tea.Batch(cmds...) - } else { - m.TextInput.Placeholder = "Type your answer or press [Tab] to auto-answer..." - m.TextInput.SetHeight(3) - m.TextInput.Focus() - } - return m, nil - } - - // Ctrl+S: Skip remaining questions and go to final review - if msg.Type == tea.KeyCtrlS { - // Save current answer - m.CollectedAnswers[m.CurrentQIdx] = strings.TrimSpace(m.TextInput.Value()) - answers := buildClarifyAnswers(m.PendingQuestions, m.CollectedAnswers) - m.State = StateClarifyingThinking - m.ThinkingStatus = "Applying answers..." - m.Msgs = append(m.Msgs, StyleSubtle.Render("Updating specification...")) - cmds = append(cmds, runClarify(m.Ctx, m.PlanApp, app.ClarifyOptions{ - Goal: m.InitialGoal, - ClarifySessionID: m.ClarifySessionID, - Answers: answers, - MaxRounds: 5, - })) - return m, tea.Batch(cmds...) - } - - m.TextInput, cmd = m.TextInput.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) - } - - // Pass keys to textInput if waiting for input - if m.State == StateClarifyingInput { - // Block input if auto-answering - if m.AutoAnswering { - return m, nil - } - - // Handle Tab for Auto-Refine - if msg.Type == tea.KeyTab { - m.AutoAnswering = true - m.LLMStartTime = time.Now() - m.addMsg("SYSTEM", "Auto-generating specification... (Esc to cancel)") - cmds = append(cmds, runAutoAnswer(m.Ctx, m.PlanApp, m.InitialGoal, m.ClarifySessionID)) - cmds = append(cmds, checkTimeout()) - return m, tea.Batch(cmds...) - } - - // Handle Ctrl+S for submission - if msg.Type == tea.KeyCtrlS { - // User submitted (possibly edited) specification - answer := m.TextInput.Value() - if strings.TrimSpace(answer) == "" { - return m, nil // Ignore empty - } - m.TextInput.Reset() - m.TextInput.SetHeight(DefaultTextareaHeight) - m.addMsg("USER", "Updated Specification:\n"+answer) - - m.EnrichedGoal = answer - m.HasUnsavedWork = true - - m.State = StatePlanningPulse - m.ThinkingStatus = "Drafting implementation plan..." - m.Msgs = append(m.Msgs, StyleSubtle.Render("Generating tasks...")) - cmds = append(cmds, listenForStream(m.Stream)) - cmds = append(cmds, runGenerate(m.Ctx, m.PlanApp, m.InitialGoal, m.ClarifySessionID, m.EnrichedGoal, m.Stream)) - return m, tea.Batch(cmds...) - } - - m.TextInput, cmd = m.TextInput.Update(msg) - m.HasUnsavedWork = true // Mark as dirty when user types - cmds = append(cmds, cmd) - } - - case MsgAutoAnswerResult: - m.AutoAnswering = false - if msg.Err == nil { - m.EnrichedGoal = msg.Answer - m.TextInput.SetValue(msg.Answer) - - // Dynamic Resizing - lines := strings.Count(msg.Answer, "\n") + 1 - estimatedLines := len(msg.Answer) / DefaultTextareaWidth - if estimatedLines > lines { - lines = estimatedLines - } - - newHeight := lines + 2 - if newHeight < DefaultTextareaHeight { - newHeight = DefaultTextareaHeight - } - if newHeight > MaxTextareaHeight { - newHeight = MaxTextareaHeight - } - m.TextInput.SetHeight(newHeight) - - } else { - m.addMsg("SYSTEM", "Auto-refine failed: "+msg.Err.Error()) - m.TextInput.SetValue(m.EnrichedGoal) - } - - case MsgSingleAutoAnswerResult: - m.AutoAnswering = false - if msg.Err != nil { - m.addMsg("WARN", formatUserFriendlyError("Auto-answer", msg.Err)) - return m, nil - } - - // Store the auto-generated answer - answer := strings.TrimSpace(msg.Answer) - if answer == "" { - answer = "(No answer generated)" - } - m.CollectedAnswers[msg.QuestionIdx] = answer - m.TextInput.SetValue(answer) - m.addMsg("ANSWER", answer) - - case MsgLLMTimeout: - m.AutoAnswering = false - m.addMsg("WARN", fmt.Sprintf("%s timed out after %ds. Press [Tab] to retry.", msg.Operation, LLMTimeoutSeconds)) - - case MsgLLMCancelled: - m.AutoAnswering = false - m.addMsg("DONE", fmt.Sprintf("Operation cancelled: %s", msg.Operation)) - - case MsgCheckTimeout: - // Periodic timeout check - only add another check if still auto-answering - if m.AutoAnswering { - cmds = append(cmds, checkTimeout()) - } - - case spinner.TickMsg: - if m.State != StateSuccess && m.State != StateError { - m.Spinner, cmd = m.Spinner.Update(msg) - cmds = append(cmds, cmd) - } - // Note: Recursive logic for state init is handled by Init() command now. - - // Context Found -> Start Clarification - case MsgContextFound: - if msg.Err != nil { - // Log error but proceed without context? Or fail? - // Let's proceed without context but show error - m.addMsg("WARN", fmt.Sprintf("Memory search failed (%v)", msg.Err)) - } else { - m.KGContext = msg.Context - if msg.Context != "" { - // Display the strategy used - if msg.Strategy != "" { - // Use strings.TrimSpace to avoid extra newlines - m.addMsg("STRATEGY", strings.TrimSpace(msg.Strategy)) - } else { - m.addMsg("DONE", "Found relevant architectural context.") - } - } else { - m.addMsg("THINKING", "No relevant memory found. Analyzing from scratch...") - } - } - - m.State = StateClarifyingThinking - m.ThinkingStatus = "Agent is clarifying the goal..." - cmds = append(cmds, runClarify(m.Ctx, m.PlanApp, app.ClarifyOptions{ - Goal: m.InitialGoal, - MaxRounds: 5, - })) - - // Clarify Result (unified via PlanApp.Clarify) - case MsgClarifyResult: - if msg.Err != nil { - m.Err = msg.Err - m.State = StateError - return m, nil - } - if msg.Result == nil || !msg.Result.Success { - errMsg := "Clarification failed" - if msg.Result != nil && msg.Result.Message != "" { - errMsg = msg.Result.Message - } - m.Err = fmt.Errorf("%s", errMsg) - m.State = StateError - return m, nil - } - - // Extract result fields - result := msg.Result - if result.EnrichedGoal != "" { - m.EnrichedGoal = result.EnrichedGoal - } - if result.GoalSummary != "" { - // Enforce max 100 chars for GoalSummary (UI display) - goalSummary := result.GoalSummary - runes := []rune(goalSummary) - if len(runes) > 100 { - goalSummary = string(runes[:97]) + "..." - } - m.GoalSummary = goalSummary - } - if result.ClarifySessionID != "" { - m.ClarifySessionID = result.ClarifySessionID - } - - // Enforce at least one review pass (ClarifyTurns > 0) even if agent is ready - if (result.IsReadyToPlan && m.ClarifyTurns > 0) || m.ClarifyTurns >= 3 { - // Ready to plan! - m.State = StatePlanningPulse - m.addMsg("DONE", "Goal finalized! Drafting implementation plan...") - m.addMsg("GOAL", m.EnrichedGoal) - - // Start Planning - // Start Pulse listener - cmds = append(cmds, listenForStream(m.Stream)) - // Start Planning Agent via PlanApp.Generate - cmds = append(cmds, runGenerate(m.Ctx, m.PlanApp, m.InitialGoal, m.ClarifySessionID, m.EnrichedGoal, m.Stream)) - - } else { - // Spec-First Refinement - m.ClarifyTurns++ - m.PendingQuestions = result.Questions - m.CurrentQIdx = 0 - m.CollectedAnswers = make([]string, len(result.Questions)) - m.ThinkingStatus = "" - - m.addMsg("AGENT", "I've drafted a technical specification based on your goal and project context.") - - if len(m.PendingQuestions) > 0 { - // Start one-by-one Q&A flow - m.State = StateAnsweringQuestion - m.addMsg("SYSTEM", fmt.Sprintf("I have %d questions to clarify. Answer each one or press [Tab] to auto-answer.", len(m.PendingQuestions))) - // Set up textarea for answering current question - m.TextInput.SetValue("") - m.TextInput.Placeholder = "Type your answer or press [Tab] to auto-answer..." - m.TextInput.SetHeight(3) - m.TextInput.Focus() - } else { - // No questions, go directly to final review - m.State = StateClarifyingInput - m.TextInput.SetValue(m.EnrichedGoal) - m.TextInput.Placeholder = "Review and edit the specification..." - m.TextInput.Focus() - - // Calculate initial height based on draft - lines := strings.Count(m.EnrichedGoal, "\n") + 1 - estimatedLines := len(m.EnrichedGoal) / DefaultTextareaWidth - if estimatedLines > lines { - lines = estimatedLines - } - newHeight := lines + 2 - if newHeight < DefaultTextareaHeight { - newHeight = DefaultTextareaHeight - } - if newHeight > MaxTextareaHeight { - newHeight = MaxTextareaHeight - } - m.TextInput.SetHeight(newHeight) - - m.addMsg("SYSTEM", "Please review the final specification below. Hit [Ctrl+S] to approve and start impl.") - } - } - - // Planning Pulse - case core.StreamEvent: - // Pulse Update - only process if still in planning state - if m.State == StatePlanningPulse { - switch msg.Type { - case core.EventNodeStart: - content := msg.Content // "prompt", "model", "parser" - niceMsg := content - if content == "prompt" { - niceMsg = "Templating..." - } - if content == "model" { - niceMsg = "Thinking..." - } - if content == "parser" { - niceMsg = "Generating tasks..." - } - - m.ThinkingStatus = niceMsg // Update status line instead of spamming log - m.addMsg("PULSE", niceMsg) - } - // Keep listening only if still in planning state - if m.State == StatePlanningPulse { - cmds = append(cmds, listenForStream(m.Stream)) - } - } - - // Generate Result (replaces PlanResult + SavedPlan) - case MsgGenerateResult: - if msg.Err != nil { - m.Err = msg.Err - m.State = StateError - return m, nil - } - m.GenerateResult = msg.Result - if msg.Result.Success { - if len(msg.Result.SemanticWarnings) > 0 || len(msg.Result.SemanticErrors) > 0 { - m.addMsg("WARN", fmt.Sprintf("Semantic validation (non-blocking): %d warning(s), %d error(s)", - len(msg.Result.SemanticWarnings), len(msg.Result.SemanticErrors))) - for _, w := range msg.Result.SemanticWarnings { - m.addMsg("WARN", w) - } - for _, e := range msg.Result.SemanticErrors { - m.addMsg("WARN", e) - } - } - m.State = StateSuccess - m.PlanID = msg.Result.PlanID - m.PlanSummary = fmt.Sprintf("Created plan %s with %d tasks", msg.Result.PlanID, len(msg.Result.Tasks)) - m.addMsg("DONE", "Plan generated and saved!") - return m, tea.Quit - } else { - m.Err = fmt.Errorf("generation failed: %s", msg.Result.Message) - m.State = StateError - } - } - - // Sync Viewport - m.Viewport.SetContent(strings.Join(m.Msgs, "\n")) - // m.Viewport.GotoBottom() // REMOVED: Breaks manual scrolling. Moved to addMsg. - m.Viewport, cmd = m.Viewport.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func (m *PlanModel) addMsg(msgType, content string) { - var fullMsg string - maxWidth := MaxMsgWidth - - switch msgType { - // === MINIMAL PREFIXES (no brackets, just icon) === - case "THINKING": - // Minimal progress - dim, no bracket noise - fullMsg = StylePrefixThinking.Render("◆ " + content) - - case "PULSE": - // Very subtle internal progress - fullMsg = StyleSubtle.Render(" › " + content) - - // === BOXED MESSAGES (special visual treatment) === - case "STRATEGY": - // Research strategy in a distinct box - header := StylePrefixStrategy.Render("🔍 Research Strategy") - // Format bullet points - lines := strings.Split(content, "\n") - var boxContent strings.Builder - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "•") { - line = "› " + strings.TrimPrefix(line, "•") - line = strings.TrimSpace(line) - } - if line != "" { - boxContent.WriteString(line + "\n") - } - } - boxed := StyleStrategyBox.Width(maxWidth).Render(strings.TrimSuffix(boxContent.String(), "\n")) - fullMsg = header + "\n" + boxed - - case "ANSWER": - // Auto-generated answer in a blue box - header := StylePrefixAnswer.Render("💡 Suggested Answer") - boxed := StyleAnswerBox.Width(maxWidth).Render(content) - fullMsg = header + "\n" + boxed + "\n" + StyleSubtle.Render("[Enter] Accept | Edit below") - - // === ICON PREFIXES (cleaner than brackets) === - case "DONE": - fullMsg = StylePrefixDone.Render("✓ " + content) - - case "WARN": - fullMsg = StylePrefixWarn.Render("⚠ " + content) - - case "ERROR": - fullMsg = StylePrefixError.Render("✗ " + content) - - case "AGENT": - wrapped := StyleText.Width(maxWidth).Render(content) - fullMsg = StylePrefixAgent.Render("◈ Agent") + " " + wrapped - - case "USER": - wrapped := StyleText.Width(maxWidth).Render(content) - fullMsg = StylePrefixUser.Render("› You") + " " + wrapped - - case "GOAL": - wrapped := StylePrimary.Width(maxWidth).Render(content) - fullMsg = StyleTitle.Render("◆ Goal") + "\n" + wrapped - - // === FALLBACK: Legacy SYSTEM messages === - case "SYSTEM": - // Simple system messages without brackets - wrapped := StyleSubtle.Width(maxWidth).Render(content) - fullMsg = wrapped - - default: - // Unknown type - just render as-is - fullMsg = StyleText.Width(maxWidth).Render(content) - } - - m.Msgs = append(m.Msgs, fullMsg) - m.Viewport.SetContent(strings.Join(m.Msgs, "\n")) - m.Viewport.GotoBottom() -} - -func buildClarifyAnswers(questions, answers []string) []app.ClarifyAnswer { - out := make([]app.ClarifyAnswer, 0, len(answers)) - for i, answer := range answers { - answer = strings.TrimSpace(answer) - if answer == "" { - continue - } - entry := app.ClarifyAnswer{Answer: answer} - if i < len(questions) { - entry.Question = questions[i] - } - out = append(out, entry) - } - return out -} - -// formatUserFriendlyError converts technical errors to user-friendly messages (P2) -func formatUserFriendlyError(operation string, err error) string { - errStr := err.Error() - - // Common error patterns - switch { - case strings.Contains(errStr, "context deadline exceeded"): - return fmt.Sprintf("⏱ %s timed out. Check your network or try again.", operation) - case strings.Contains(errStr, "context canceled"): - return fmt.Sprintf("%s was cancelled.", operation) - case strings.Contains(errStr, "connection refused"): - return "⚠ Cannot connect to LLM service. Is the API available?" - case strings.Contains(errStr, "rate limit"): - return "⚠ Rate limited by API. Wait a moment and try again." - case strings.Contains(errStr, "401"), strings.Contains(errStr, "unauthorized"): - return "⚠ API authentication failed. Check your API key." - case strings.Contains(errStr, "500"), strings.Contains(errStr, "internal server"): - return "⚠ LLM service error. Try again in a moment." - default: - // Truncate long errors - if len(errStr) > 100 { - errStr = errStr[:97] + "..." - } - return fmt.Sprintf("⚠ %s failed: %s", operation, errStr) - } -} - -func (m PlanModel) View() string { - var s strings.Builder - - // === OVERLAYS (render on top of everything) === - - // Help Overlay (P2) - if m.ShowHelp { - s.WriteString(StyleHeader.Render("◆ Keyboard Shortcuts") + "\n\n") - s.WriteString(StyleTitle.Render("Global:") + "\n") - s.WriteString(" ? Toggle this help\n") - s.WriteString(" Esc Cancel operation / Go back\n") - s.WriteString(" Ctrl+C Quit immediately\n") - s.WriteString(" q Quit (with confirmation)\n\n") - s.WriteString(StyleTitle.Render("Question Mode:") + "\n") - s.WriteString(" Tab Auto-answer current question\n") - s.WriteString(" Enter Submit answer, next question\n") - s.WriteString(" ←/Ctrl+P Previous question\n") - s.WriteString(" →/Ctrl+N Next question (skip)\n") - s.WriteString(" Ctrl+S Skip to final review\n") - s.WriteString(" Ctrl+Z Undo last answer\n\n") - s.WriteString(StyleTitle.Render("Spec Review Mode:") + "\n") - s.WriteString(" Tab Auto-refine specification\n") - s.WriteString(" Ctrl+S Submit specification\n\n") - s.WriteString(StyleTitle.Render("Viewport (non-input):") + "\n") - s.WriteString(" j/k Scroll down/up\n") - s.WriteString(" g/G Go to top/bottom\n\n") - s.WriteString(StyleSubtle.Render("Press any key to close")) - return s.String() - } - - // Quit Confirmation (P0) - if m.ShowQuitConfirm { - s.WriteString("\n\n") - s.WriteString(StyleWarning.Render("⚠ Quit and lose progress?") + "\n\n") - s.WriteString(" [y] Yes, quit\n") - s.WriteString(" [n] No, continue\n") - s.WriteString(" [Esc] Cancel\n") - return s.String() - } - - // === NORMAL VIEW === - - // Header (compact) - s.WriteString(StyleHeader.Render("◆ TaskWing Planning Session")) - goalPreview := m.InitialGoal - if len(goalPreview) > 60 { - goalPreview = goalPreview[:57] + "..." - } - s.WriteString(" " + StyleSubtle.Render("Goal: "+goalPreview) + " " + StyleSubtle.Render("[?] Help") + "\n") - - // Separator - sepWidth := m.Viewport.Width - if sepWidth < 40 { - sepWidth = 40 - } - s.WriteString(StyleSubtle.Render(strings.Repeat("─", sepWidth)) + "\n") - - // Viewport (Chat History) - s.WriteString(m.Viewport.View() + "\n") - - // Separator before input - s.WriteString(StyleSubtle.Render(strings.Repeat("─", sepWidth)) + "\n") - - // Status Line / Input Area - switch m.State { - case StateInitializing: - s.WriteString(m.Spinner.View() + " " + m.ThinkingStatus) - - case StateAnsweringQuestion: - // Display current question with progress - progress := fmt.Sprintf("Question %d/%d", m.CurrentQIdx+1, len(m.PendingQuestions)) - s.WriteString(StyleWarning.Render("? "+progress) + "\n") - currentQ := m.PendingQuestions[m.CurrentQIdx] - // Wrap question text to fit viewport width - qWidth := sepWidth - 4 // Account for indent - if qWidth < 40 { - qWidth = 40 - } - wrappedQ := StyleText.Width(qWidth).Render(currentQ) - // Indent each line - for _, line := range strings.Split(wrappedQ, "\n") { - s.WriteString(" " + line + "\n") - } - s.WriteString(StyleInputBox.Render(m.TextInput.View()) + "\n") - if m.AutoAnswering { - s.WriteString(m.Spinner.View() + StylePrimary.Render(" Generating answer... (Esc to cancel)")) - } else { - s.WriteString(StyleSubtle.Render("[Tab] Auto | [Enter] Submit | [←/→] Nav | [?] Help")) - } - - case StateClarifyingInput: - boxStyle := StyleInputBox - stateLabel := StyleWarning.Render("✎ Editing") - if len(m.PendingQuestions) == 0 || m.CurrentQIdx >= len(m.PendingQuestions) { - boxStyle = StyleReadyBox - stateLabel = StyleSuccess.Render("✓ Ready to Submit") - } - s.WriteString(stateLabel + "\n") - s.WriteString(boxStyle.Render(m.TextInput.View()) + "\n") - if m.AutoAnswering { - s.WriteString(m.Spinner.View() + StylePrimary.Render(" Auto-generating specification...")) - } else { - s.WriteString(StyleSubtle.Render("[Tab] Auto-Answer | [Ctrl+S] Submit | [q] Quit")) - } - - case StateClarifyingThinking, StatePlanningThinking, StatePlanningPulse: - s.WriteString(m.Spinner.View() + " " + m.ThinkingStatus) - - case StateError: - s.WriteString(StyleError.Render("✗ Error: "+m.Err.Error()) + "\n") - s.WriteString(StyleSubtle.Render("Press [q] or [Ctrl+C] to exit")) - - case StateSuccess: - s.WriteString(StyleSuccess.Render("✓ Plan saved. Rendering plan view...")) - } - - s.WriteString("\n") - return s.String() -} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index b722eab..520e576 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -101,8 +101,8 @@ var ( // Ask Output Styles StyleAskHeader = lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true).Padding(0, 0) - StyleAskMeta = lipgloss.NewStyle().Foreground(ColorDim) - StyleCitationPath = lipgloss.NewStyle().Foreground(ColorDim).Italic(true) + StyleAskMeta = StyleSubtle // Reuse subtle for dim metadata + StyleCitationPath = lipgloss.NewStyle().Foreground(ColorSecondary).Italic(true) // Subtle + italic StyleCitationBadge = lipgloss.NewStyle().Foreground(ColorCyan).Bold(true) ) diff --git a/internal/ui/table.go b/internal/ui/table.go index 46e200b..288f9db 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -136,11 +136,9 @@ func (t *Table) Render() string { if i < len(row) { val = row[i] } - // Truncate if needed (guard against zero/small widths) - if widths[i] >= 2 && len(val) > widths[i] { - val = val[:widths[i]-1] + "…" - } else if widths[i] == 1 && len(val) > 1 { - val = "…" + // Truncate if needed using display width (rune-safe) + if lipgloss.Width(val) > widths[i] { + val = truncateToWidth(val, widths[i]) } cells = append(cells, cellStyle.Render(padRight(val, widths[i]))) } @@ -150,12 +148,14 @@ func (t *Table) Render() string { return sb.String() } -// padRight pads a string to the specified width. +// padRight pads a string to the specified display width. +// Uses lipgloss.Width for correct handling of multi-byte and wide characters. func padRight(s string, width int) string { - if len(s) >= width { + visible := lipgloss.Width(s) + if visible >= width { return s } - return s + strings.Repeat(" ", width-len(s)) + return s + strings.Repeat(" ", width-visible) } // GetTerminalWidthFor returns the terminal width for the given file descriptor, defaulting to 80. @@ -172,6 +172,25 @@ func GetTerminalWidth() int { return GetTerminalWidthFor(os.Stdout) } +// truncateToWidth truncates a string to fit within maxWidth display columns, +// appending "..." if truncated. Safe for multi-byte and wide characters. +func truncateToWidth(s string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + if lipgloss.Width(s) <= maxWidth { + return s + } + runes := []rune(s) + for i := len(runes); i > 0; i-- { + candidate := string(runes[:i]) + "..." + if lipgloss.Width(candidate) <= maxWidth { + return candidate + } + } + return s[:maxWidth] +} + // TruncateID shortens an ID for display (first 6 chars). func TruncateID(id string) string { if len(id) > 6 { diff --git a/internal/ui/utils.go b/internal/ui/utils.go index 6c70f4c..ea8653f 100644 --- a/internal/ui/utils.go +++ b/internal/ui/utils.go @@ -109,20 +109,6 @@ func RenderWarningPanel(title, content string) string { return NewPanel(title, content).WithBorderColor(ColorWarning).Render() } -// Truncate truncates a string to maxLen characters, adding ellipsis if needed. -func Truncate(s string, maxLen int) string { - if maxLen <= 0 { - return s - } - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} - // WrapText wraps text to the specified width. func WrapText(text string, width int) string { if width <= 0 { diff --git a/opencode.json b/opencode.json deleted file mode 100644 index 8346e65..0000000 --- a/opencode.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "taskwing": { - "type": "local", - "command": [ - "taskwing", - "mcp" - ], - "timeout": 5000 - } - } -} \ No newline at end of file diff --git a/scripts/check-doc-consistency.sh b/scripts/check-doc-consistency.sh deleted file mode 100755 index ecf6500..0000000 --- a/scripts/check-doc-consistency.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -"$ROOT/scripts/sync-docs.sh" --check - -stale_patterns=( - '\\btw\\s+(bootstrap|context|add|plan new|task list|hook)\\b' - 'taskwing context' - 'taskwing add' - 'OpenAI, Ollama support' -) - -found=0 -for pattern in "${stale_patterns[@]}"; do - if rg -n --glob '*.md' -e "$pattern" "$ROOT"; then - echo "check-doc-consistency: stale pattern detected -> $pattern" >&2 - found=1 - fi -done - -if [[ "$found" -ne 0 ]]; then - exit 1 -fi - -echo "check-doc-consistency: markdown consistency checks passed" diff --git a/scripts/sync-docs.sh b/scripts/sync-docs.sh deleted file mode 100755 index 8644abd..0000000 --- a/scripts/sync-docs.sh +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -MODE="write" -if [[ "${1:-}" == "--check" ]]; then - MODE="check" -fi - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PARTIALS_DIR="$ROOT/docs/_partials" - -TARGETS=( - "$ROOT/README.md" - "$ROOT/docs/TUTORIAL.md" - "$ROOT/docs/PRODUCT_VISION.md" - "$ROOT/CLAUDE.md" - "$ROOT/GEMINI.md" -) - -REQUIRED_MARKERS=( - "TASKWING_PROVIDERS:$PARTIALS_DIR/providers.md" - "TASKWING_TOOLS:$PARTIALS_DIR/tools.md" - "TASKWING_COMMANDS:$PARTIALS_DIR/core_commands.md" -) - -OPTIONAL_MARKERS=( - "TASKWING_MCP_TOOLS:$PARTIALS_DIR/mcp_tools.md" - "TASKWING_LEGAL:$PARTIALS_DIR/legal.md" -) - -apply_marker() { - local src="$1" - local dst="$2" - local marker="$3" - local partial="$4" - local required="$5" - - local start="" - local end="" - local start_count end_count - - start_count=$(grep -Fc "$start" "$src" || true) - end_count=$(grep -Fc "$end" "$src" || true) - - if [[ "$required" == "true" ]]; then - if [[ "$start_count" -ne 1 || "$end_count" -ne 1 ]]; then - echo "sync-docs: $src must contain exactly one marker pair for $marker" >&2 - return 1 - fi - else - if [[ "$start_count" -eq 0 && "$end_count" -eq 0 ]]; then - cp "$src" "$dst" - return 0 - fi - if [[ "$start_count" -ne 1 || "$end_count" -ne 1 ]]; then - echo "sync-docs: $src has malformed optional marker pair for $marker" >&2 - return 1 - fi - fi - - awk -v start="$start" -v end="$end" ' - BEGIN { in_block=0; start_seen=0; end_seen=0; block="" } - FNR==NR { - block = block $0 ORS - next - } - { - if ($0 == start) { - print $0 - printf "%s", block - in_block=1 - start_seen++ - next - } - if ($0 == end) { - if (in_block != 1) { - exit 42 - } - in_block=0 - end_seen++ - print $0 - next - } - if (!in_block) { - print $0 - } - } - END { - if (in_block == 1) { - exit 43 - } - if (start_seen != 1 || end_seen != 1) { - exit 44 - } - } - ' "$partial" "$src" > "$dst" -} - -out_of_sync=0 - -for target in "${TARGETS[@]}"; do - if [[ ! -f "$target" ]]; then - echo "sync-docs: missing target file $target" >&2 - exit 1 - fi - - current="$target" - tmp_files=() - - for entry in "${REQUIRED_MARKERS[@]}"; do - marker="${entry%%:*}" - partial="${entry#*:}" - tmp_next="$(mktemp)" - apply_marker "$current" "$tmp_next" "$marker" "$partial" "true" - tmp_files+=("$tmp_next") - current="$tmp_next" - done - - for entry in "${OPTIONAL_MARKERS[@]}"; do - marker="${entry%%:*}" - partial="${entry#*:}" - tmp_next="$(mktemp)" - apply_marker "$current" "$tmp_next" "$marker" "$partial" "false" - tmp_files+=("$tmp_next") - current="$tmp_next" - done - - if [[ "$MODE" == "check" ]]; then - if ! cmp -s "$target" "$current"; then - echo "sync-docs: out-of-sync file: $target" >&2 - diff -u "$target" "$current" || true - out_of_sync=1 - fi - else - cp "$current" "$target" - fi - - for tmp in "${tmp_files[@]}"; do - rm -f "$tmp" - done - -done - -if [[ "$MODE" == "check" && "$out_of_sync" -ne 0 ]]; then - exit 1 -fi - -if [[ "$MODE" == "check" ]]; then - echo "sync-docs: all managed markdown blocks are in sync" -else - echo "sync-docs: updated managed markdown blocks" -fi diff --git a/skills/skills.go b/skills/skills.go new file mode 100644 index 0000000..9b26d1c --- /dev/null +++ b/skills/skills.go @@ -0,0 +1,67 @@ +// Package skills provides embedded skill definitions for TaskWing. +// +// Skills are self-contained SKILL.md files. They are the single source of truth +// for prompt content used by both: +// - Claude Code commands (generated as .claude/commands/taskwing/*.md with embedded content) +// - MCP tool responses (for non-Claude-Code tools) +package skills + +import ( + "embed" + "fmt" + "strings" +) + +//go:embed taskwing-*/SKILL.md +var content embed.FS + +// Get returns the SKILL.md content for a given skill name (e.g., "next", "done", "plan"). +// The name maps to the directory taskwing-/SKILL.md. +func Get(name string) (string, error) { + data, err := content.ReadFile(fmt.Sprintf("taskwing-%s/SKILL.md", name)) + if err != nil { + return "", fmt.Errorf("skill %q not found: %w", name, err) + } + return string(data), nil +} + +// GetBody returns only the body content (after YAML frontmatter) for a given skill. +// This strips the --- frontmatter block, returning just the prompt instructions. +func GetBody(name string) (string, error) { + full, err := Get(name) + if err != nil { + return "", err + } + return stripFrontmatter(full), nil +} + +// List returns all available skill names (without the "taskwing-" prefix). +func List() ([]string, error) { + entries, err := content.ReadDir(".") + if err != nil { + return nil, err + } + var names []string + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(e.Name(), "taskwing-") { + names = append(names, strings.TrimPrefix(e.Name(), "taskwing-")) + } + } + return names, nil +} + +// stripFrontmatter removes YAML frontmatter (--- delimited block) from the content. +func stripFrontmatter(s string) string { + if !strings.HasPrefix(s, "---") { + return s + } + // Find the closing --- + rest := s[3:] + _, body, found := strings.Cut(rest, "\n---") + if !found { + return s + } + // Skip past any trailing newline after --- + body = strings.TrimPrefix(body, "\n") + return body +} diff --git a/skills/taskwing-context/SKILL.md b/skills/taskwing-context/SKILL.md new file mode 100644 index 0000000..ebea632 --- /dev/null +++ b/skills/taskwing-context/SKILL.md @@ -0,0 +1,52 @@ +--- +name: taskwing-context +description: Use when you need the full project knowledge dump for complete architectural context. +--- + +# Project Context Dump + +Inject the complete project knowledge base into this conversation so you have full architectural context. + +## When to Use + +- At the start of a session when you need to understand the project before making changes +- When the user says "what do you know about this project" +- When you need to check constraints before implementing something +- When planning work that touches multiple parts of the codebase + +## Kill Table + +| Impulse | Do Instead | +|---------|------------| +| Summarize or paraphrase the results | Show everything verbatim so the user can verify | +| Filter out "less important" knowledge | Present all nodes. You do not decide relevance for the user. | +| Modify the knowledge base | This is strictly read-only. Use `remember` MCP tool to persist changes. | +| Use this to bypass plan/verification gates | Context priming is not a substitute for workflow checkpoints | + +## Operating Principles + +1. **Constraints first.** Always present constraints before decisions and patterns. They are mandatory rules. +2. **Decisions second.** Technology and architecture choices frame the project. +3. **Patterns third.** Recurring practices inform how to write code in this project. +4. **Completeness over brevity.** Do not omit nodes. The user needs the full picture. + +## Steps + +1. Call MCP tool `ask` with a broad query to retrieve all knowledge: +```json +{"query": "project decisions patterns constraints features", "all": true} +``` + +2. Present the returned knowledge organized by type: + - **Constraints** first (mandatory rules) + - **Decisions** (technology and architecture choices) + - **Patterns** (recurring practices) + - **Features** (product capabilities) + +3. After presenting, confirm: "Project context loaded. I now have full visibility into your architecture. What would you like to work on?" + +## Important + +- This is a READ-ONLY operation. It does not modify the knowledge base. +- If the knowledge base is empty, tell the user to run `tw bootstrap` first. +- Do NOT summarize or filter the results. Show everything so the user can verify. diff --git a/skills/taskwing-done/SKILL.md b/skills/taskwing-done/SKILL.md new file mode 100644 index 0000000..7b932e7 --- /dev/null +++ b/skills/taskwing-done/SKILL.md @@ -0,0 +1,104 @@ +--- +name: taskwing-done +description: Use when implementation is verified and you are ready to complete the current task. +--- + +# Complete Task with Architecture-Aware Summary + +The Workflow Contract lives in CLAUDE.md (single source of truth). Obey it always. + +## Kill Table + +| Impulse | Do Instead | +|---------|------------| +| Mark complete without running verification | STOP with refusal text. Evidence is non-negotiable. | +| Reuse verification output from earlier in the conversation | Run fresh checks in this completion attempt | +| Skip acceptance criteria check | Every criterion must be explicitly addressed (met/not met/partial) | +| Silently drop unmet criteria | Call them out. Partial completion is honest; silent omission is not. | + +## Operating Principles + +1. **Verification is non-negotiable.** Every completion requires fresh evidence from this attempt. +2. **Evidence must be fresh.** "I ran tests earlier" does not count. Run them now. +3. **Acceptance criteria are explicit.** Each one gets a verdict: met, not met, or partial with explanation. + +Execute these steps IN ORDER. + +## Step 1: Get Current Task +Call MCP tool `task` with action `current`: +```json +{"action": "current"} +``` + +If no active task, inform user and stop. + +## Step 2: Collect Fresh Verification Evidence +Run the most relevant verification commands for the task (tests, lint, build, or targeted checks). + +Document: +- command run +- exit status +- short output snippet proving pass/fail + +If verification was not run in this completion attempt, STOP and respond with: +"REFUSAL: I can't mark this task done yet. Verification evidence is missing. Run fresh checks and include the output." + +## Step 3: Generate Completion Report + +Create a structured summary covering: + +### Files Modified +List all files changed with purpose of change. + +### Acceptance Criteria Verification +For each criterion: +- **Met**: [How it was satisfied] +- **Not Met**: [Why, and what's needed] +- **Partial**: [What was done, what remains] + +### Pattern Compliance +Confirm alignment with codebase patterns. + +### Technical Debt / Follow-ups +- TODOs introduced +- Tests not written +- Edge cases not handled + +## Step 4: Completion Gate (Hard Gate) +Before calling `task complete`, confirm: +- evidence is fresh (from Step 2) +- acceptance criteria status is explicit +- unresolved failures are called out + +If any item is missing, STOP and use the refusal text above. + +## Step 5: Mark Complete +Call MCP tool `task` with action `complete`: +```json +{ + "action": "complete", + "task_id": "[task_id]", + "summary": "[The structured summary from Step 2]", + "files_modified": ["path/to/file1.go", "path/to/file2.go"] +} +``` + +## Step 6: Confirm to User + +Display: +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +TASK COMPLETE: [task_id] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[Summary report] + +Recorded in TaskWing memory. +Use /taskwing:next to continue with next priority task. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## Fallback (No MCP) +```bash +taskwing task complete TASK_ID +``` diff --git a/skills/taskwing-next/SKILL.md b/skills/taskwing-next/SKILL.md new file mode 100644 index 0000000..75c03e0 --- /dev/null +++ b/skills/taskwing-next/SKILL.md @@ -0,0 +1,125 @@ +--- +name: taskwing-next +description: Use when you are ready to start the next approved TaskWing task with full context. +--- + +# Start Next TaskWing Task with Full Context + +The Workflow Contract lives in CLAUDE.md (single source of truth). Obey it always. + +## Kill Table + +| Impulse | Do Instead | +|---------|------------| +| Skip `ask` calls for context | Always fetch scope + task context before coding | +| Start coding without approval | Present the brief, wait for explicit checkpoint approval | +| Ignore patterns/constraints from ask | Patterns are binding, constraints are mandatory | + +## Operating Principles + +1. **Context before code.** Always call `ask` for both scope and task-specific context before presenting the brief. +2. **The brief is the contract.** The task brief (Step 5) defines what you will build. Do not deviate without re-checking. +3. **Patterns are binding.** If `ask` returns patterns for the task scope, follow them. If constraints exist, respect them. + +Execute these steps IN ORDER. Do not skip any step. + +## Step 1: Get Next Task +Call MCP tool `task` with action `next` to retrieve the highest-priority pending task: +```json +{"action": "next"} +``` + +`session_id` is optional when called through MCP transport; include it only for explicit cross-session orchestration. + +Extract from the response: +- task_id, title, description +- scope (e.g., "auth", "vectorsearch", "api") +- keywords array +- acceptance_criteria +- suggested_ask_queries + +If no task returned, inform user: "No pending tasks. Use /taskwing:context to check plan status." + +## Step 2: Fetch Scope-Relevant Context +Call MCP tool `ask` with query based on task scope: +```json +{"query": "[task.scope] patterns constraints decisions"} +``` + +Examples: +- scope "auth" -> `{"query": "authentication cookies session patterns"}` +- scope "api" -> `{"query": "api handlers middleware patterns"}` +- scope "vectorsearch" -> `{"query": "lancedb embedding vector patterns"}` + +Extract: patterns, constraints, related decisions. + +## Step 3: Fetch Task-Specific Context +Call MCP tool `ask` with keywords from the task. +Use `suggested_ask_queries` if available, otherwise extract keywords from title. +```json +{"query": "[keywords from task title/description]"} +``` + +## Step 4: Claim the Task +Call MCP tool `task` with action `start`: +```json +{"action": "start", "task_id": "[task_id from step 1]"} +``` + +## Step 5: Present Unified Task Brief + +Display in this format: +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +TASK: [task_id] (Priority: [priority]) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**[Title]** + +## Description +[Full task description] + +## Acceptance Criteria +- [ ] [Criterion 1] +- [ ] [Criterion 2] +- [ ] [Criterion 3] + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ARCHITECTURE CONTEXT +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +## Relevant Patterns +[Patterns from ask that apply to this task] + +## Constraints +[Constraints that must be respected] + +## Related Decisions +[Past decisions that inform this work] + +## Key Files +[Files likely to be modified based on context] + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Task claimed. Ready to begin. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## Step 6: Implementation Start Gate (Hard Gate) +Before writing or editing code, ask for an explicit checkpoint: +"Implementation checkpoint: proceed with task [task_id] now?" + +If approval is missing or unclear, STOP and respond with: +"REFUSAL: I can't start implementation yet. Plan/task checkpoint is incomplete. Please approve this task checkpoint first." + +## Step 7: Begin Implementation (Only After Approval) +Proceed with the task, following the patterns and respecting the constraints shown above. + +**CRITICAL**: You MUST call all MCP tools (`task(next)`, `ask` x2, `task(start)`) before showing the brief and before requesting implementation approval. + +## Fallback (No MCP) +```bash +taskwing task list # List all tasks +taskwing task list --status pending # Identify next pending task +``` +Use /taskwing:context to check active plan progress. diff --git a/skills/taskwing-plan/SKILL.md b/skills/taskwing-plan/SKILL.md new file mode 100644 index 0000000..8f6b387 --- /dev/null +++ b/skills/taskwing-plan/SKILL.md @@ -0,0 +1,279 @@ +--- +name: taskwing-plan +description: Use when you need to clarify a goal and build an approved execution plan. +argument-hint: "[goal description] or [--batch goal description]" +--- + +# Create Development Plan with Goal + +**Usage:** `/taskwing:plan ` or `/taskwing:plan --batch ` + +**Example:** `/taskwing:plan Add Stripe billing integration` + +The Workflow Contract lives in CLAUDE.md (single source of truth). Obey it always. + +## Kill Table + +| Impulse | Do Instead | +|---------|------------| +| Auto-answer clarifying questions yourself | Present them to the user and WAIT | +| Skip the clarification checkpoint | STOP with refusal text | +| Plan without clarifying first | Always run clarify before generate | +| Assume the user approved | Require explicit "approve", "yes", or equivalent | + +## Operating Principles + +1. **Goal clarity first.** Never generate a plan from a vague goal. Run clarify until is_ready_to_plan is true. +2. **User approves at every gate.** Checkpoints are hard gates, not suggestions. Missing approval = STOP. +3. **Auto-start after finalize.** Once the plan is approved, immediately call `task(next)` with auto_start=true. Do not wait for the user to say "/taskwing:next". + +Hard gate for this command: +- Do NOT generate, decompose, expand, or finalize a plan until the clarified goal checkpoint is explicitly approved. +- If approval is missing, STOP and respond with: + "REFUSAL: I can't move past planning yet. Clarification checkpoint is incomplete. Please approve the clarified goal first." + +## Mode Selection + +The plan tool supports two modes: +- **Interactive (default)**: Staged workflow with checkpoints at phases and tasks +- **Batch (--batch flag)**: Original all-at-once generation + +Check if $ARGUMENTS contains "--batch" flag: +- If yes: Use batch mode (Steps 1-4) +- If no: Use interactive mode (Steps 1-8) + +--- + +# BATCH MODE (when --batch is used) + +## Step 0: Check for Goal + +**If $ARGUMENTS is empty or not provided:** +Ask the user: "What do you want to build? Please describe your goal." +Wait for user response, then use that as the goal. + +**If $ARGUMENTS is provided:** +Use $ARGUMENTS as the goal and proceed to Step 1. + +## Step 1: Initial Clarification + +Call MCP tool `plan` with action `clarify` and the user's goal: +```json +{"action": "clarify", "goal": "[goal from Step 0]"} +``` + +Extract: clarify_session_id, questions, goal_summary, enriched_goal, is_ready_to_plan, context_used. + +## Step 2: Ask Clarifying Questions (Loop) + +**CRITICAL: Do NOT answer questions yourself. Present them to the user and WAIT.** + +**If is_ready_to_plan is false:** +Present each question to the user exactly as returned. The questions include options (e.g., "Option A vs Option B"). Wait for the user to pick or modify. + +**If user says "auto" or "skip":** +Call `plan` again with action `clarify`, clarify_session_id, and auto_answer: true. + +**If user provides answers:** +Format answers as JSON and call `plan` again with action `clarify` and clarify_session_id: +```json +{ + "action": "clarify", + "clarify_session_id": "[clarify_session_id from previous clarify step]", + "answers": [{"question":"...","answer":"..."}] +} +``` + +Repeat until is_ready_to_plan is true. + +## Step 3: Clarification Checkpoint Approval (Hard Gate) +Before generating: +- present enriched_goal and assumptions +- ask for explicit approval ("approve", "yes", or equivalent) + +If approval is not explicit, STOP and use the refusal text above. + +## Step 4: Generate Plan + +When is_ready_to_plan is true, call MCP tool `plan` with action `generate`: +```json +{ + "action": "generate", + "goal": "$ARGUMENTS", + "clarify_session_id": "[clarify_session_id from clarify loop]", + "enriched_goal": "[enriched_goal from step 2]", + "save": true +} +``` + +## Step 5: Present Plan Summary + +Display the generated plan: +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +PLAN CREATED: [plan_id] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Goal:** [goal] + +## Generated Tasks + +| # | Title | Priority | +|---|-------|----------| +| 1 | [Task 1 title] | [priority] | +| 2 | [Task 2 title] | [priority] | +... + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Plan saved and set as active. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +**After presenting the plan summary, immediately start working on the first task.** +Call MCP tool `task` with action=`next` and auto_start=true. Do NOT wait for the user to say "/taskwing:next" -- the plan was just approved, start executing. + +--- + +# INTERACTIVE MODE (default when no --batch flag) + +## Step 1: Check for Goal (Same as Batch) + +**If $ARGUMENTS is empty or not provided:** +Ask the user: "What do you want to build? Please describe your goal." +Wait for user response, then use that as the goal. + +## Step 2: Clarify Goal + +Call MCP tool `plan` with action=clarify: +```json +{"action": "clarify", "goal": "[goal from Step 1]", "mode": "interactive"} +``` + +**CRITICAL: Present all clarifying questions to the user. Do NOT answer them yourself.** +The questions include options the user can pick from. Wait for the user to respond before proceeding. +If user says "auto" or "skip", call clarify with auto_answer: true. + +Loop until is_ready_to_plan is true. +Save the clarify_session_id and enriched_goal for subsequent steps. + +**CHECKPOINT 1**: User approves the enriched goal before proceeding. +If approval is not explicit, STOP and use the refusal text above. + +## Step 3: Decompose into Phases + +Call MCP tool `plan` with action=decompose: +```json +{ + "action": "decompose", + "clarify_session_id": "[clarify_session_id from Step 2]", + "enriched_goal": "[enriched_goal from Step 2]" +} +``` + +This returns 3-5 high-level phases. Present them to the user: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +PROPOSED PHASES +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +## Phase 1: [Title] +[Description] +Rationale: [Why this phase is needed] +Expected tasks: [N] + +## Phase 2: [Title] +... + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +**CHECKPOINT 2**: Ask user to: +- Approve phases as-is +- Request regeneration with feedback +- Skip specific phases + +## Step 4: Expand Each Phase (Loop) + +For each approved phase, call MCP tool `plan` with action=expand: +```json +{ + "action": "expand", + "plan_id": "[plan_id]", + "phase_id": "[phase_id]" +} +``` + +This returns 2-4 detailed tasks for the phase. Present them: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +TASKS FOR PHASE: [Phase Title] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +## Task 1: [Title] +Priority: [priority] +Description: [description] +Acceptance Criteria: +- [criterion 1] +- [criterion 2] + +## Task 2: [Title] +... + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Remaining phases: [N] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +**CHECKPOINT 3** (per phase): Ask user to: +- Approve tasks and continue to next phase +- Request regeneration with feedback +- Skip this phase + +Repeat for each phase until all are expanded. + +## Step 5: Finalize Plan + +After all phases are expanded, call MCP tool `plan` with action=finalize: +```json +{ + "action": "finalize", + "plan_id": "[plan_id]" +} +``` + +## Step 6: Present Final Summary + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +PLAN FINALIZED: [plan_id] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Goal:** [goal] + +## Phases & Tasks + +### Phase 1: [Title] + 1. [Task 1 title] (Priority: [P]) + 2. [Task 2 title] (Priority: [P]) + +### Phase 2: [Title] + 3. [Task 3 title] (Priority: [P]) + 4. [Task 4 title] (Priority: [P]) + +... + +**Total:** [N] phases, [M] tasks +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Plan saved and set as active. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +**After presenting the plan summary, immediately start working on the first task.** +Call MCP tool `task` with action=`next` and auto_start=true. Do NOT wait for the user to say "/taskwing:next" -- the plan was just approved, start executing. + +--- + +## Fallback (No MCP) +Use /taskwing:plan in your AI tool to create and manage plans.