From d506a026350ccbc6a5820c485cd46f059ad63980 Mon Sep 17 00:00:00 2001 From: Joseph Goksu Date: Mon, 16 Mar 2026 00:04:37 +0000 Subject: [PATCH 1/5] fix: security hardening, code quality, and freshness review fixes Security (3 medium-severity): - Block find -exec/-execdir/-delete/-ok in ExecTool (injection risk) - Add "--" before grep pattern to prevent flag injection - Replace ad-hoc path validation in MCP handlers with safepath.SafeJoin - Use safepath.SafeJoin in policy builtins resolvePath (traversal risk) - Change crash log permissions from 0644 to 0600 Code quality: - Add rows.Err() checks after all 8 rows.Next() loops in codeintel/repository.go - Replace err == io.EOF with errors.Is(err, io.EOF) in 3 files - Fix out.Close() error handling in memory_export.go (named return) Freshness review fixes (Greptile PR #26 feedback): - Fix decay discontinuity: unified formula (1.0 - ratio * 0.8) replaces hardcoded 0.2 all-missing + 0.4 formula partial-missing - Add cache eviction (purge entries >2x TTL when cache exceeds 1000) - Convert global cache to struct-based statCache (testable, bounded) - Add TODO(freshness-level2) comments for dead code paths - Mark freshness plan doc as "implemented with deviations" - Add TestDecaySmoothCurve test verifying no discontinuities --- cmd/memory_export.go | 8 +- docs/positioning/freshness-validation-plan.md | 2 +- internal/agents/tools/eino.go | 12 ++- internal/app/ask.go | 11 ++- internal/app/explain.go | 3 +- internal/codeintel/repository.go | 24 ++++++ internal/freshness/freshness.go | 73 ++++++++++++------- internal/freshness/freshness_test.go | 69 +++++++++++++----- internal/knowledge/classify.go | 3 +- internal/logger/crash.go | 2 +- internal/mcp/handlers.go | 42 ++--------- internal/memory/sqlite.go | 1 + internal/policy/builtins.go | 42 +++++++++-- 13 files changed, 195 insertions(+), 97 deletions(-) diff --git a/cmd/memory_export.go b/cmd/memory_export.go index 5668fa5..5a8648f 100644 --- a/cmd/memory_export.go +++ b/cmd/memory_export.go @@ -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/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/internal/agents/tools/eino.go b/internal/agents/tools/eino.go index f735121..606f6ce 100644 --- a/internal/agents/tools/eino.go +++ b/internal/agents/tools/eino.go @@ -155,7 +155,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 +346,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/app/ask.go b/internal/app/ask.go index 0a9f73b..ac55c6f 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" @@ -491,7 +492,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 +529,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 +562,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/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/freshness/freshness.go b/internal/freshness/freshness.go index a5b42e3..f84d80f 100644 --- a/internal/freshness/freshness.go +++ b/internal/freshness/freshness.go @@ -83,7 +83,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 +99,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 +180,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 +212,34 @@ 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() + c.entries[path] = cacheEntry{info: info, err: err, checkedAt: time.Now()} + // Evict expired entries when cache grows too large + if len(c.entries) > cacheMaxSize { + c.evictExpired() + } + 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) + } + } +} + +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..d03ad79 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/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/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..737e280 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -6,7 +6,6 @@ import ( "fmt" "log/slog" "os" - "path/filepath" "strings" agentcore "github.com/josephgoksu/TaskWing/internal/agents/core" @@ -16,6 +15,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 +361,17 @@ 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) - } - - // Resolve to absolute path - var absPath string - if filepath.IsAbs(cleanPath) { - absPath = cleanPath - } else { - if projectRoot == "" { - return "", fmt.Errorf("cannot resolve relative path without project root") - } - absPath = filepath.Join(projectRoot, cleanPath) + if projectRoot == "" { + return "", fmt.Errorf("cannot resolve path without project root") } - // 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) - } + // SafeJoin resolves symlinks and ensures the result stays within projectRoot + absPath, err := safepath.SafeJoin(projectRoot, 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/memory/sqlite.go b/internal/memory/sqlite.go index aee1563..0c9cb81 100644 --- a/internal/memory/sqlite.go +++ b/internal/memory/sqlite.go @@ -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/policy/builtins.go b/internal/policy/builtins.go index a388a5c..72b8122 100644 --- a/internal/policy/builtins.go +++ b/internal/policy/builtins.go @@ -3,6 +3,7 @@ package policy import ( "bufio" "context" + "fmt" "path/filepath" "regexp" "strings" @@ -13,6 +14,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 +47,20 @@ 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.SafeJoin to prevent path traversal via "../" in policy inputs. +func (bc *BuiltinContext) resolvePath(path string) (string, error) { if filepath.IsAbs(path) { - return path + // Verify absolute path is within WorkDir + absWorkDir, err := filepath.Abs(bc.WorkDir) + if err != nil { + return "", fmt.Errorf("resolve work dir: %w", err) + } + if !strings.HasPrefix(filepath.Clean(path), absWorkDir) { + return "", fmt.Errorf("path outside work directory: %s", path) + } + return path, nil } - return filepath.Join(bc.WorkDir, path) + return safepath.SafeJoin(bc.WorkDir, path) } // RegisterBuiltins registers all TaskWing custom built-ins with OPA. @@ -173,7 +184,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 +210,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 +232,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 +314,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 +350,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 } From c5e40f9630a9838a18d4f956e22a3bdb7b1f506a Mon Sep 17 00:00:00 2001 From: Joseph Goksu Date: Tue, 17 Mar 2026 23:39:26 +0000 Subject: [PATCH 2/5] Refactor migration tests and enhance TUI error handling - Updated migration tests to clarify directory structure and improve comments for better understanding. - Enhanced error handling in the TUI by treating agent completion warnings as non-fatal, allowing for smoother user experience. - Improved list view rendering by adding workspace checks and refactoring header rendering for better clarity. - Introduced new skills for TaskWing, including detailed SKILL.md files for various commands to enhance project knowledge and workflow. - Added functionality to simplify code while preserving behavior, ensuring that optimization does not bypass necessary gates. - Implemented status command to provide current task progress and acceptance criteria, improving user awareness of task status. --- cmd/bootstrap.go | 50 +- cmd/slash.go | 32 +- cmd/slash_content.go | 777 ------------------ .../impl/analysis_code_deterministic.go | 17 +- internal/agents/impl/analysis_deps.go | 52 +- internal/agents/impl/analysis_doc.go | 60 +- internal/agents/impl/analysis_git.go | 46 +- internal/agents/impl/react_bootstrap.go | 80 ++ internal/app/ask.go | 11 +- internal/bootstrap/factory.go | 76 +- internal/bootstrap/initializer.go | 82 +- internal/bootstrap/mcp_healthcheck_test.go | 2 +- internal/bootstrap/planner.go | 96 +-- internal/bootstrap/runner.go | 108 ++- internal/config/prompts.go | 253 +++++- internal/llm/models.go | 224 +++-- internal/mcp/presenter.go | 58 +- internal/migration/upgrade.go | 24 +- internal/migration/upgrade_test.go | 32 +- internal/ui/bootstrap_tui.go | 22 +- internal/ui/list_view.go | 212 +++-- internal/ui/table.go | 35 +- skills/skills.go | 67 ++ skills/taskwing-ask/SKILL.md | 22 + skills/taskwing-debug/SKILL.md | 91 ++ skills/taskwing-done/SKILL.md | 92 +++ skills/taskwing-explain/SKILL.md | 76 ++ skills/taskwing-next/SKILL.md | 116 +++ skills/taskwing-plan/SKILL.md | 265 ++++++ skills/taskwing-remember/SKILL.md | 22 + skills/taskwing-simplify/SKILL.md | 62 ++ skills/taskwing-status/SKILL.md | 49 ++ 32 files changed, 2087 insertions(+), 1124 deletions(-) delete mode 100644 cmd/slash_content.go create mode 100644 internal/agents/impl/react_bootstrap.go create mode 100644 skills/skills.go create mode 100644 skills/taskwing-ask/SKILL.md create mode 100644 skills/taskwing-debug/SKILL.md create mode 100644 skills/taskwing-done/SKILL.md create mode 100644 skills/taskwing-explain/SKILL.md create mode 100644 skills/taskwing-next/SKILL.md create mode 100644 skills/taskwing-plan/SKILL.md create mode 100644 skills/taskwing-remember/SKILL.md create mode 100644 skills/taskwing-simplify/SKILL.md create mode 100644 skills/taskwing-status/SKILL.md diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 984dfed..cbb80e2 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -222,24 +222,50 @@ 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 + byType := make(map[string]int) + for _, n := range nodes { + t := n.Type + if t == "" { + t = "unknown" + } + byType[t]++ + } + + // Build stats string using canonical type order + var stats []string + for _, t := range memory.AllNodeTypes() { + if count := byType[t]; count > 0 { + stats = append(stats, fmt.Sprintf("%s %d", ui.TypeIcon(t), count)) + } + } + + fmt.Printf("\n Knowledge: %d nodes (%s)\n", len(nodes), strings.Join(stats, " | ")) + fmt.Println(" Run 'taskwing knowledge' to explore, or start Claude Code -- it already has context.") +} + // 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 { @@ -515,7 +541,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 diff --git a/cmd/slash.go b/cmd/slash.go index 8c963e8..24e3ded 100644 --- a/cmd/slash.go +++ b/cmd/slash.go @@ -9,21 +9,10 @@ import ( "strings" "github.com/josephgoksu/TaskWing/internal/bootstrap" + "github.com/josephgoksu/TaskWing/skills" "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", @@ -61,20 +50,19 @@ func init() { rootCmd.AddCommand(slashCmd) for _, slash := range bootstrap.SlashCommands { - content, ok := slashContents[slash.SlashCmd] - if !ok { - continue - } - + name := slash.SlashCmd short := fmt.Sprintf("Output /%s command content", slash.BaseName) c := &cobra.Command{ - Use: slash.SlashCmd, + Use: name, Short: short, - Run: func(content string) func(*cobra.Command, []string) { - return func(cmd *cobra.Command, args []string) { - fmt.Print(content) + RunE: func(cmd *cobra.Command, args []string) error { + body, err := skills.GetBody(name) + if err != nil { + return fmt.Errorf("load skill content: %w", err) } - }(content), + fmt.Print(body) + return nil + }, } 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/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..16d9c39 100644 --- a/internal/agents/impl/analysis_deps.go +++ b/internal/agents/impl/analysis_deps.go @@ -5,8 +5,10 @@ package impl import ( "context" + "errors" "fmt" "io" + "log" "os" "os/exec" "strings" @@ -43,7 +45,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 +93,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 +268,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(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..2d353ba 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" "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 { + log.Printf("[doc] ReAct produced only %d findings (threshold %d), falling back to deterministic", len(findings), reactMinFindingsDoc) + } + } + } + if err != nil && !errors.Is(err, ErrNoToolCalling) { + log.Printf("[doc] ReAct mode failed, falling back to deterministic: %v", 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/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/app/ask.go b/internal/app/ask.go index ac55c6f..5f4c85c 100644 --- a/internal/app/ask.go +++ b/internal/app/ask.go @@ -456,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 diff --git a/internal/bootstrap/factory.go b/internal/bootstrap/factory.go index adb7495..ecb615c 100644 --- a/internal/bootstrap/factory.go +++ b/internal/bootstrap/factory.go @@ -1,17 +1,81 @@ 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" ) // 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. +func hasDependencyFiles(basePath string) bool { + for _, name := range dependencyManifests { + if _, err := os.Stat(filepath.Join(basePath, name)); err == nil { + return true + } + } + // Also check for go.mod in subdirectories (monorepo) + if entries, err := os.ReadDir(basePath); err == nil { + for _, e := range entries { + if !e.IsDir() { + continue + } + for _, name := range dependencyManifests { + if _, err := os.Stat(filepath.Join(basePath, e.Name(), name)); err == nil { + return true + } + } + } + } + return false +} + diff --git a/internal/bootstrap/initializer.go b/internal/bootstrap/initializer.go index 27537bb..8dce2dc 100644 --- a/internal/bootstrap/initializer.go +++ b/internal/bootstrap/initializer.go @@ -11,6 +11,8 @@ import ( "sort" "strings" "time" + + "github.com/josephgoksu/TaskWing/skills" ) // Initializer handles the setup of TaskWing project structure and integrations. @@ -238,10 +240,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}, @@ -372,6 +375,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)) @@ -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/ @@ -574,6 +585,75 @@ description: %s return nil } +// createClaudeSkills generates .claude/commands/taskwing/.md with embedded content. +// Unlike legacy commands that shell out to `taskwing slash `, these embed the full +// prompt content directly, removing the CLI indirection. +// 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:ask, /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) + } + if verbose { + fmt.Printf(" ✓ Created %s/%s/%s\n", cfg.commandsDir, slashCommandNamespace, fileName) + } + } + + if err := pruneStaleSlashCommands(commandsDir, cfg.fileExt, verbose); err != nil { + 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-") { + _ = os.RemoveAll(filepath.Join(skillsDir, e.Name())) + 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 +} + // createSingleFileInstructions generates a single instructions file for AIs that use this format // (like GitHub Copilot's .github/copilot-instructions.md) instead of a directory of slash command files. func (i *Initializer) createSingleFileInstructions(aiName string, verbose bool) error { 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..51f1249 100644 --- a/internal/bootstrap/planner.go +++ b/internal/bootstrap/planner.go @@ -779,78 +779,58 @@ 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 plan.RequiresRepoSelection && len(plan.DetectedRepos) > 0 { + fmt.Fprintf(&sb, "Workspace: %d repositories detected\n", len(plan.DetectedRepos)) + } + // Show what will happen if len(plan.Actions) > 0 { - actionNames := make([]string, len(plan.Actions)) - for i, a := range plan.Actions { - actionNames[i] = string(a) + sb.WriteString("\n") + for _, summary := range plan.ActionSummary { + fmt.Fprintf(&sb, " %s\n", summary) } - fmt.Fprintf(&sb, " actions=[%s]", strings.Join(actionNames, ",")) } - if len(plan.Warnings) > 0 { - fmt.Fprintf(&sb, " warnings=%d", len(plan.Warnings)) - } + // 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, ",")) + fmt.Fprintf(&sb, "\n Detected unmanaged config: %s\n", strings.Join(plan.UnmanagedDriftAIs, ", ")) + sb.WriteString(" Run 'taskwing doctor --fix --adopt-unmanaged' to claim.\n") } if len(plan.GlobalMCPDriftAIs) > 0 { - fmt.Fprintf(&sb, " global_mcp_drift_detected=%s", strings.Join(plan.GlobalMCPDriftAIs, ",")) + fmt.Fprintf(&sb, "\n Missing global MCP: %s\n", strings.Join(plan.GlobalMCPDriftAIs, ", ")) + sb.WriteString(" Run 'taskwing doctor --fix' to repair.\n") } - 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..48c470c 100644 --- a/internal/bootstrap/runner.go +++ b/internal/bootstrap/runner.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "strings" "sync" "time" @@ -19,7 +20,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 +41,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 +60,106 @@ 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: true, 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, _ := runParallel(ctx, wave1Agents, input) + // Wave 1 errors are non-fatal: code and git work independently. + // Partial wave1 results still provide useful context for wave 2. + + // 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 +169,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 +177,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 +191,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/config/prompts.go b/internal/config/prompts.go index c384ee4..8ab47cc 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" } @@ -728,6 +758,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/llm/models.go b/internal/llm/models.go index 8303160..a1909c7 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) @@ -567,40 +633,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/mcp/presenter.go b/internal/mcp/presenter.go index e8a6856..f37fc43 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") } diff --git a/internal/migration/upgrade.go b/internal/migration/upgrade.go index b86d29c..fae032b 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,33 @@ 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 } } + // For Claude, also check legacy .claude/commands/ marker (migration from commands to skills) + if !managed && aiName == "claude" { + legacyMarker := filepath.Join(projectDir, ".claude", "commands", bootstrap.TaskWingManagedFile) + if _, err := os.Stat(legacyMarker); 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..6fbc6f5 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 + askCmd := filepath.Join(nsDir, "ask.md") + if _, err := os.Stat(askCmd); os.IsNotExist(err) { + t.Fatal("taskwing/ask.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/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/list_view.go b/internal/ui/list_view.go index 8a85960..058dd3d 100644 --- a/internal/ui/list_view.go +++ b/internal/ui/list_view.go @@ -33,18 +33,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) + } +} + +// 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 +70,72 @@ 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) { + 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 - ) - - // Header - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorPrimary).Underline(true) - dimSep := StyleSubtle.Render(" ") + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorText) + itemStyle := lipgloss.NewStyle().Foreground(ColorText) + itemStyleAlt := lipgloss.NewStyle().Foreground(ColorDim) + wsStyle := lipgloss.NewStyle().Foreground(ColorDim) - 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)), - ) - - // Separator - fmt.Printf(" %s\n", StyleSubtle.Render(strings.Repeat("─", colBadge+colSummary+colWorkspace+6))) - - // 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] + "…" + for _, t := range typeOrder { + groupNodes := byType[t] + if len(groupNodes) == 0 { + continue } - workspace := r.node.Workspace - if workspace == "" { - workspace = "root" - } + // 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)) + + // Items + for i, n := range groupNodes { + summary := n.Summary + if summary == "" { + summary = utils.Truncate(n.Text(), maxSummaryWidth) + } + if lipgloss.Width(summary) > maxSummaryWidth { + summary = truncateToWidth(summary, maxSummaryWidth) + } - badge := CategoryBadge(r.node.Type) + // Alternating styles for readability + style := itemStyle + if i%2 == 1 { + style = itemStyleAlt + } - // 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\n", style.Render(padRight(summary, maxSummaryWidth)), wsStyle.Render(ws)) + } else { + fmt.Printf(" %s\n", style.Render(summary)) + } } - - 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 +146,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 +184,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/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/skills/skills.go b/skills/skills.go new file mode 100644 index 0000000..44fb1f9 --- /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) +// - CLI output (taskwing slash 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-ask/SKILL.md b/skills/taskwing-ask/SKILL.md new file mode 100644 index 0000000..082d3da --- /dev/null +++ b/skills/taskwing-ask/SKILL.md @@ -0,0 +1,22 @@ +--- +name: taskwing-ask +description: Use when you need to search project knowledge (decisions, patterns, constraints). +--- + +# 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. diff --git a/skills/taskwing-debug/SKILL.md b/skills/taskwing-debug/SKILL.md new file mode 100644 index 0000000..98921e7 --- /dev/null +++ b/skills/taskwing-debug/SKILL.md @@ -0,0 +1,91 @@ +--- +name: taskwing-debug +description: Use when an issue requires root-cause-first debugging before proposing fixes. +argument-hint: "[problem description]" +--- + +# 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 +``` diff --git a/skills/taskwing-done/SKILL.md b/skills/taskwing-done/SKILL.md new file mode 100644 index 0000000..9196acf --- /dev/null +++ b/skills/taskwing-done/SKILL.md @@ -0,0 +1,92 @@ +--- +name: taskwing-done +description: Use when implementation is verified and you are ready to complete the current task. +--- + +# 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 +``` diff --git a/skills/taskwing-explain/SKILL.md b/skills/taskwing-explain/SKILL.md new file mode 100644 index 0000000..5c5eea3 --- /dev/null +++ b/skills/taskwing-explain/SKILL.md @@ -0,0 +1,76 @@ +--- +name: taskwing-explain +description: Use when you need a deep explanation of a code symbol and its call graph. +argument-hint: "[symbol_name]" +--- + +# 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 +``` diff --git a/skills/taskwing-next/SKILL.md b/skills/taskwing-next/SKILL.md new file mode 100644 index 0000000..6043ecd --- /dev/null +++ b/skills/taskwing-next/SKILL.md @@ -0,0 +1,116 @@ +--- +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 + +## 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 +``` diff --git a/skills/taskwing-plan/SKILL.md b/skills/taskwing-plan/SKILL.md new file mode 100644 index 0000000..e02dda2 --- /dev/null +++ b/skills/taskwing-plan/SKILL.md @@ -0,0 +1,265 @@ +--- +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` + +## 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 +``` diff --git a/skills/taskwing-remember/SKILL.md b/skills/taskwing-remember/SKILL.md new file mode 100644 index 0000000..7c929de --- /dev/null +++ b/skills/taskwing-remember/SKILL.md @@ -0,0 +1,22 @@ +--- +name: taskwing-remember +description: Use when you want to persist a decision, pattern, or insight to project memory. +--- + +# 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/skills/taskwing-simplify/SKILL.md b/skills/taskwing-simplify/SKILL.md new file mode 100644 index 0000000..900dc74 --- /dev/null +++ b/skills/taskwing-simplify/SKILL.md @@ -0,0 +1,62 @@ +--- +name: taskwing-simplify +description: Use when you want to simplify code while preserving behavior. +argument-hint: "[file_path or paste code]" +--- + +# 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 +``` diff --git a/skills/taskwing-status/SKILL.md b/skills/taskwing-status/SKILL.md new file mode 100644 index 0000000..fe180aa --- /dev/null +++ b/skills/taskwing-status/SKILL.md @@ -0,0 +1,49 @@ +--- +name: taskwing-status +description: Use when you need current task progress and acceptance criteria status. +--- + +# 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 +``` From 6d9ed36b7935c78e8d74b6ae0d8b7425dcc5fa5f Mon Sep 17 00:00:00 2001 From: Joseph Goksu Date: Wed, 18 Mar 2026 08:34:06 +0000 Subject: [PATCH 3/5] refactor(plan): remove CLI commands, keep MCP-only planning - Remove cmd/plan.go, cmd/goal.go, and plan TUI (1,982 lines deleted) - MCP plan tool (clarify/generate/decompose/expand/finalize/audit) unchanged - Planning prompt now scales task count dynamically to goal complexity - Tasks include self-contained context (files, patterns, constraints, tech stack) - Task enricher always provides baseline project context (constraints + decisions) - Fix stop hook reliability: sync TasksCompleted from DB on each invocation - Add session save retry logic to prevent stale state from failed writes - Clear stale CurrentTaskID when DB lookup fails --- cmd/goal.go | 209 ------- cmd/hook.go | 45 +- cmd/plan.go | 693 ----------------------- internal/app/plan.go | 14 +- internal/config/prompts.go | 30 +- internal/ui/plan_tui.go | 1080 ------------------------------------ 6 files changed, 59 insertions(+), 2012 deletions(-) delete mode 100644 cmd/goal.go delete mode 100644 cmd/plan.go delete mode 100644 internal/ui/plan_tui.go 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..05c3a1e 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -249,13 +249,28 @@ func runContinueCheck(maxTasks, maxMinutes int) error { }) } - // 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 + session.CurrentTaskID = "" + if viper.GetBool("verbose") { + fmt.Fprintf(os.Stderr, "[DEBUG] Could not load current task %s: %v\n", session.CurrentTaskID, err) + } } } @@ -269,7 +284,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 +304,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 +325,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 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/internal/app/plan.go b/internal/app/plan.go index e6a1a00..2112bf8 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -154,16 +154,20 @@ func NewPlanApp(ctx *Context) *PlanApp { return pa } -// defaultTaskEnricher executes all ask queries and aggregates results into a context summary. +// defaultTaskEnricher executes ask queries and aggregates results into a context summary. +// If no explicit queries are provided, it falls back to fetching project constraints +// and decisions so every task has baseline project context. // This is the production implementation; tests can override TaskEnricher for mocking. func (a *PlanApp) defaultTaskEnricher(ctx context.Context, queries []string) (string, error) { - if len(queries) == 0 { - return "", nil - } - askApp := NewAskApp(a.ctx) var contextParts []string + // Always include project constraints and key decisions as baseline context. + // This ensures every task knows the project's rules even without specific queries. + if len(queries) == 0 { + queries = []string{"project constraints and rules", "key technology decisions"} + } + for _, query := range queries { result, err := askApp.Query(ctx, query, AskOptions{ Limit: 3, // 3 results per query diff --git a/internal/config/prompts.go b/internal/config/prompts.go index 8ab47cc..a14bf92 100644 --- a/internal/config/prompts.go +++ b/internal/config/prompts.go @@ -546,16 +546,28 @@ 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:** +Scale the number of tasks to the actual complexity of the goal. Do NOT over-decompose. +- Simple change (fix a bug, rename, add a field): 1 task +- Small feature (new endpoint, new component): 1-2 tasks +- Medium feature (new service, new page with backend): 2-4 tasks +- Large feature (new subsystem, major refactor): 4-6 tasks +- System-wide change (migration, architecture shift): 5-8 tasks + +NEVER generate more tasks than the goal actually requires. If you can do it in 1 task, use 1 task. + **Guidelines:** 1. **Atomic Tasks**: Each task must be a clear unit of work (e.g., "Create database schema", "Implement auth middleware"). -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. +2. **Self-Contained Context**: Each task MUST include enough context in its description to be executed independently by any AI coding agent, even without seeing the full plan. Include: + - Which files to create or modify + - Which existing patterns/conventions to follow (from the Knowledge Graph) + - Relevant constraints that apply + - The tech stack and libraries to use +3. **Dependencies**: Respect logical order. A task cannot rely on something not yet built. +4. **Context Aware**: Use the provided Knowledge Graph Context. Link tasks to existing Features/Patterns if mentioned. +5. **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. +6. **Verification**: For each task, define clear acceptance criteria and a validation command (e.g., "go test ./..."). +7. **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. When a caller provides an explicit tasks array, use those tasks directly instead of generating new ones. **Input Context:** - Enriched Goal: {{.Goal}} @@ -566,7 +578,7 @@ 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 step-by-step instructions including file paths, patterns to follow, constraints to respect, and tech stack context. Must be self-contained enough for an independent AI agent to execute.", "acceptance_criteria": ["Criteria 1", "Criteria 2"], "validation_steps": ["go test ./..."], "priority": 80, // 0-100 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() -} From 9e504889032ae9fee91ddcd12be6af6ba9b846df Mon Sep 17 00:00:00 2001 From: Joseph Goksu Date: Wed, 18 Mar 2026 08:47:19 +0000 Subject: [PATCH 4/5] fix: address PR review comments (security + cache + path handling) - policy/builtins.go: use safepath.ValidateAbsPath for absolute paths, fixes prefix collision bypass (e.g. /project-evil matching /project) - freshness/freshness.go: add evictOldest fallback when all cache entries are fresh but cache exceeds max size during burst scenarios - mcp/handlers.go: restore absolute path support in validateAndResolvePath via safepath.ValidateAbsPath, fixing regression for MCP clients - agents/impl/analysis_deps.go: use filepath.Join instead of string concat - bootstrap/factory.go: use safepath.SafeJoin for dependency file checks --- internal/agents/impl/analysis_deps.go | 3 ++- internal/bootstrap/factory.go | 17 +++++++++++++---- internal/freshness/freshness.go | 26 +++++++++++++++++++++++++- internal/mcp/handlers.go | 11 +++++++++-- internal/policy/builtins.go | 13 ++----------- 5 files changed, 51 insertions(+), 19 deletions(-) diff --git a/internal/agents/impl/analysis_deps.go b/internal/agents/impl/analysis_deps.go index 16d9c39..3a9dfa8 100644 --- a/internal/agents/impl/analysis_deps.go +++ b/internal/agents/impl/analysis_deps.go @@ -11,6 +11,7 @@ import ( "log" "os" "os/exec" + "path/filepath" "strings" "github.com/josephgoksu/TaskWing/internal/agents/core" @@ -278,7 +279,7 @@ var commonDepFiles = []string{ // hasAnyDependencyFile checks if at least one common dependency manifest exists. func hasAnyDependencyFile(basePath string) bool { for _, name := range commonDepFiles { - if _, err := os.Stat(basePath + "/" + name); err == nil { + if _, err := os.Stat(filepath.Join(basePath, name)); err == nil { return true } } diff --git a/internal/bootstrap/factory.go b/internal/bootstrap/factory.go index ecb615c..0f5fc65 100644 --- a/internal/bootstrap/factory.go +++ b/internal/bootstrap/factory.go @@ -2,11 +2,11 @@ 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. @@ -57,20 +57,29 @@ var dependencyManifests = []string{ } // 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 { - if _, err := os.Stat(filepath.Join(basePath, name)); err == nil { + p, err := safepath.SafeJoin(basePath, name) + if err != nil { + continue + } + if _, err := os.Stat(p); err == nil { return true } } - // Also check for go.mod in subdirectories (monorepo) + // 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 { - if _, err := os.Stat(filepath.Join(basePath, e.Name(), name)); err == nil { + p, err := safepath.SafeJoin(basePath, e.Name()+"/"+name) + if err != nil { + continue + } + if _, err := os.Stat(p); err == nil { return true } } diff --git a/internal/freshness/freshness.go b/internal/freshness/freshness.go index f84d80f..3c12a33 100644 --- a/internal/freshness/freshness.go +++ b/internal/freshness/freshness.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -214,9 +215,13 @@ func (c *statCache) stat(path string) (os.FileInfo, error) { c.mu.Lock() c.entries[path] = cacheEntry{info: info, err: err, checkedAt: time.Now()} - // Evict expired entries when cache grows too large + // 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() @@ -233,6 +238,25 @@ func (c *statCache) evictExpired() { } } +// 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) diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 737e280..aa565a4 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "os" + "path/filepath" "strings" agentcore "github.com/josephgoksu/TaskWing/internal/agents/core" @@ -368,8 +369,14 @@ func validateAndResolvePath(requestedPath string, projectRoot string) (string, e return "", fmt.Errorf("cannot resolve path without project root") } - // SafeJoin resolves symlinks and ensures the result stays within projectRoot - absPath, err := safepath.SafeJoin(projectRoot, requestedPath) + // Support both relative and absolute paths within the project root + var absPath string + var err error + if filepath.IsAbs(requestedPath) { + absPath, err = safepath.ValidateAbsPath(projectRoot, requestedPath) + } else { + absPath, err = safepath.SafeJoin(projectRoot, requestedPath) + } if err != nil { return "", fmt.Errorf("path not allowed: %w", err) } diff --git a/internal/policy/builtins.go b/internal/policy/builtins.go index 72b8122..53b4f42 100644 --- a/internal/policy/builtins.go +++ b/internal/policy/builtins.go @@ -3,7 +3,6 @@ package policy import ( "bufio" "context" - "fmt" "path/filepath" "regexp" "strings" @@ -47,18 +46,10 @@ func NewBuiltinContextWithCodeIntel(workDir string, repo codeintel.Repository) * } // resolvePath converts a relative path to absolute using the work directory. -// Uses safepath.SafeJoin to prevent path traversal via "../" in policy inputs. +// 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) { - // Verify absolute path is within WorkDir - absWorkDir, err := filepath.Abs(bc.WorkDir) - if err != nil { - return "", fmt.Errorf("resolve work dir: %w", err) - } - if !strings.HasPrefix(filepath.Clean(path), absWorkDir) { - return "", fmt.Errorf("path outside work directory: %s", path) - } - return path, nil + return safepath.ValidateAbsPath(bc.WorkDir, path) } return safepath.SafeJoin(bc.WorkDir, path) } From 1c8c0bc5807c13e82931b34a20fab000ca3d9a7a Mon Sep 17 00:00:00 2001 From: Joseph Goksu Date: Wed, 18 Mar 2026 18:55:06 +0000 Subject: [PATCH 5/5] feat: unified context API, bootstrap UX polish, model updates, skill improvements - Unified GetProjectContext replaces 3 separate retrieval paths (ContextRetriever, TaskEnricher, buildTaskContext) - Bootstrap: fix duplicate "Bootstrap complete!" in multi-repo, batch link warnings into single summary, clean verification output - Bootstrap: per-service project context in workspace mode, progress callbacks, repo count accuracy - Models: add gpt-5.4/5.4-mini/5.4-nano, claude-opus-4-6/sonnet-4-6, gemini-3.1, remove deprecated models - Models: newest-first ordering in selection UI - UI: grouped knowledge output, freshness indicators, consistent header boxes across all commands - UI: markdown rendering in ask --answer output, adaptive width - Skills: add /taskwing:context, remove /taskwing:debug and /taskwing:simplify, fix stale file pruning - Skills: clarify questions presented to user (not auto-answered), auto-start first task after plan creation - Hooks: stop hook syncs TasksCompleted from DB, session save retry, verbose logs gated behind --debug - MCP: behavioral CLAUDE.md instructions tell AI tools when to use TaskWing - Prompts: generic and directive (no hardcoded tech stack examples), dynamic task count scaling - Fix: read_file tool returns helpful message for directories instead of crashing ReAct agent - Fix: auto-regenerate slash commands on MCP server start after brew upgrade - Fix: truncateString consolidated to utils.Truncate, nil guards in context.go, brief.go pluralization - Remove: CLI plan/goal/slash commands, docs/_partials, sync-docs scripts, stale doc references --- .github/workflows/ci.yml | 8 - AGENTS.md | 5 +- CLAUDE.md | 9 +- GEMINI.md | 5 - README.md | 10 +- cmd/bootstrap.go | 76 +++-- cmd/doctor.go | 6 +- cmd/hook.go | 54 ++-- cmd/knowledge.go | 6 +- cmd/memory_export.go | 4 +- cmd/root.go | 16 +- cmd/slash.go | 70 ----- cmd/task.go | 52 ++-- docs/PRODUCT_VISION.md | 7 +- docs/TUTORIAL.md | 12 +- docs/WORKFLOW_PACK.md | 2 +- docs/_partials/core_commands.md | 10 - docs/_partials/legal.md | 1 - docs/_partials/mcp_tools.md | 8 - docs/_partials/providers.md | 5 - docs/_partials/tools.md | 6 - go.mod | 4 +- go.sum | 4 - internal/agents/core/parsers_test.go | 18 +- internal/agents/impl/planning_context.go | 311 --------------------- internal/agents/tools/eino.go | 9 +- internal/agents/verification/agent.go | 1 - internal/app/plan.go | 108 ++++--- internal/app/task.go | 4 +- internal/bootstrap/bootstrap_repro_test.go | 14 +- internal/bootstrap/factory.go | 1 - internal/bootstrap/initializer.go | 88 +++--- internal/bootstrap/integration_health.go | 2 +- internal/bootstrap/planner.go | 41 +-- internal/bootstrap/runner.go | 4 +- internal/bootstrap/service.go | 76 +++-- internal/brief/brief.go | 14 +- internal/config/paths.go | 10 - internal/config/prompts.go | 125 +++------ internal/freshness/freshness_test.go | 14 +- internal/git/git_test.go | 24 +- internal/knowledge/context.go | 307 ++++++++++++++++++++ internal/knowledge/ingest.go | 58 ++-- internal/llm/models.go | 18 +- internal/llm/tokens.go | 6 - internal/mcp/presenter.go | 2 +- internal/memory/sqlite.go | 20 +- internal/project/detect_test.go | 1 - internal/task/models.go | 4 +- internal/ui/bootstrap_view.go | 115 ++++++++ internal/ui/context_view.go | 134 +++++++-- internal/ui/drift.go | 73 ++--- internal/ui/explain.go | 30 +- internal/ui/list_view.go | 53 ++-- internal/ui/styles.go | 4 +- internal/ui/utils.go | 14 - opencode.json | 13 - scripts/check-doc-consistency.sh | 27 -- scripts/sync-docs.sh | 152 ---------- skills/skills.go | 2 +- skills/taskwing-context/SKILL.md | 36 +++ skills/taskwing-debug/SKILL.md | 91 ------ skills/taskwing-next/SKILL.md | 4 +- skills/taskwing-plan/SKILL.md | 30 +- skills/taskwing-simplify/SKILL.md | 62 ---- skills/taskwing-status/SKILL.md | 2 +- 66 files changed, 1121 insertions(+), 1381 deletions(-) delete mode 100644 cmd/slash.go delete mode 100644 docs/_partials/core_commands.md delete mode 100644 docs/_partials/legal.md delete mode 100644 docs/_partials/mcp_tools.md delete mode 100644 docs/_partials/providers.md delete mode 100644 docs/_partials/tools.md delete mode 100644 internal/agents/impl/planning_context.go create mode 100644 internal/knowledge/context.go create mode 100644 internal/ui/bootstrap_view.go delete mode 100644 opencode.json delete mode 100755 scripts/check-doc-consistency.sh delete mode 100755 scripts/sync-docs.sh create mode 100644 skills/taskwing-context/SKILL.md delete mode 100644 skills/taskwing-debug/SKILL.md delete mode 100644 skills/taskwing-simplify/SKILL.md 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/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..fa25888 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 @@ -252,11 +249,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 cbb80e2..0c0c6b0 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 { @@ -244,7 +247,7 @@ func printPostBootstrapSummary() { return } - // Count by type + // Count by type using human-readable labels byType := make(map[string]int) for _, n := range nodes { t := n.Type @@ -254,16 +257,29 @@ func printPostBootstrapSummary() { byType[t]++ } - // Build stats string using canonical type order + // 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 { - stats = append(stats, fmt.Sprintf("%s %d", ui.TypeIcon(t), count)) + 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 start Claude Code -- it already has context.") + fmt.Printf("\n Knowledge: %d nodes (%s)\n", len(nodes), strings.Join(stats, ", ")) + fmt.Println(" Run 'taskwing knowledge' to explore, or use /taskwing:ask in your AI tool.") } // executeAction executes a single bootstrap action. @@ -396,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 } @@ -698,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 } @@ -712,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/hook.go b/cmd/hook.go index 05c3a1e..f441273 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -245,7 +245,7 @@ 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.", }) } @@ -350,34 +350,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 @@ -414,7 +428,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 5a8648f..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) 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 24e3ded..0000000 --- a/cmd/slash.go +++ /dev/null @@ -1,70 +0,0 @@ -/* -Copyright © 2025 Joseph Goksu josephgoksu@gmail.com -*/ -package cmd - -import ( - "fmt" - "sort" - "strings" - - "github.com/josephgoksu/TaskWing/internal/bootstrap" - "github.com/josephgoksu/TaskWing/skills" - "github.com/spf13/cobra" -) - -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 { - name := slash.SlashCmd - short := fmt.Sprintf("Output /%s command content", slash.BaseName) - c := &cobra.Command{ - Use: name, - Short: short, - RunE: func(cmd *cobra.Command, args []string) error { - body, err := skills.GetBody(name) - if err != nil { - return fmt.Errorf("load skill content: %w", err) - } - fmt.Print(body) - return nil - }, - } - - slashCmd.AddCommand(c) - } -} 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..45dc38a 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 @@ -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_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/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/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/tools/eino.go b/internal/agents/tools/eino.go index 606f6ce..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 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/plan.go b/internal/app/plan.go index 2112bf8..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,62 +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 ask queries and aggregates results into a context summary. -// If no explicit queries are provided, it falls back to fetching project constraints -// and decisions so every task has baseline project context. -// This is the production implementation; tests can override TaskEnricher for mocking. -func (a *PlanApp) defaultTaskEnricher(ctx context.Context, queries []string) (string, error) { - askApp := NewAskApp(a.ctx) - var contextParts []string +// 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 - // Always include project constraints and key decisions as baseline context. - // This ensures every task knows the project's rules even without specific queries. - if len(queries) == 0 { - queries = []string{"project constraints and rules", "key technology decisions"} + pc, err := knowledge.GetProjectContext(ctx, ks, opts) + if err != nil { + return "", err } - 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 - } + return pc.Format(), nil +} - 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")) - } - } +// defaultTaskEnricher uses GetProjectContext with compact options to enrich tasks. +func (a *PlanApp) defaultTaskEnricher(ctx context.Context, queries []string) (string, error) { + if a.ctx == nil || a.ctx.Repo == nil { + return "", nil } - if len(contextParts) == 0 { - return "", nil + ks := knowledge.NewService(a.ctx.Repo, a.ctx.LLMCfg) + + // 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, " ") + } + + 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. @@ -317,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 } } @@ -575,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 } } @@ -1374,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 } } @@ -1548,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..5e929c0 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 /taskwing:status to check progress.", }, 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 0f5fc65..ccf68a3 100644 --- a/internal/bootstrap/factory.go +++ b/internal/bootstrap/factory.go @@ -87,4 +87,3 @@ func hasDependencyFiles(basePath string) bool { } return false } - diff --git a/internal/bootstrap/initializer.go b/internal/bootstrap/initializer.go index 8dce2dc..c4c5c55 100644 --- a/internal/bootstrap/initializer.go +++ b/internal/bootstrap/initializer.go @@ -302,9 +302,8 @@ var SlashCommands = []SlashCommand{ {"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. @@ -343,17 +342,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"}, @@ -422,13 +418,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"} + 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 } @@ -552,21 +555,22 @@ func (i *Initializer) CreateSlashCommands(aiName string, verbose bool) error { } 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) @@ -586,8 +590,7 @@ description: %s } // createClaudeSkills generates .claude/commands/taskwing/.md with embedded content. -// Unlike legacy commands that shell out to `taskwing slash `, these embed the full -// prompt content directly, removing the CLI indirection. +// 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"] @@ -824,14 +827,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") @@ -1162,36 +1164,22 @@ 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 - - -[![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/) - - -### Works With +This project uses TaskWing for architectural knowledge management. You have access to TaskWing MCP tools. - -[![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/planner.go b/internal/bootstrap/planner.go index 51f1249..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 { @@ -796,8 +803,12 @@ func FormatPlanSummary(plan *Plan, quiet bool) string { // Human-readable summary fmt.Fprintf(&sb, "%s\n", plan.DetectedState) - if plan.RequiresRepoSelection && len(plan.DetectedRepos) > 0 { - fmt.Fprintf(&sb, "Workspace: %d repositories detected\n", len(plan.DetectedRepos)) + 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)) + } } // Show what will happen @@ -816,10 +827,8 @@ func FormatPlanSummary(plan *Plan, quiet bool) string { fmt.Fprintf(&sb, "\n Detected unmanaged config: %s\n", strings.Join(plan.UnmanagedDriftAIs, ", ")) sb.WriteString(" Run 'taskwing doctor --fix --adopt-unmanaged' to claim.\n") } - if len(plan.GlobalMCPDriftAIs) > 0 { - fmt.Fprintf(&sb, "\n Missing global MCP: %s\n", strings.Join(plan.GlobalMCPDriftAIs, ", ")) - sb.WriteString(" Run 'taskwing doctor --fix' to repair.\n") - } + // Global MCP drift is not shown in bootstrap plan summary. + // Use 'tw doctor' for optional global MCP setup. if len(plan.SkippedActions) > 0 { sb.WriteString("\n Skipped:\n") diff --git a/internal/bootstrap/runner.go b/internal/bootstrap/runner.go index 48c470c..2e9ec9a 100644 --- a/internal/bootstrap/runner.go +++ b/internal/bootstrap/runner.go @@ -60,7 +60,7 @@ func (r *Runner) RunWithOptions(ctx context.Context, projectPath string, opts Ru BasePath: projectPath, ProjectName: filepath.Base(projectPath), Mode: core.ModeBootstrap, - Verbose: true, + Verbose: false, // Only enable with --verbose or --debug flags Workspace: workspace, } @@ -114,7 +114,7 @@ func splitAgentsByWave(agents []core.Agent) (wave1, wave2 []core.Agent) { // 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 maxDescLen = 200 // Truncate individual descriptions const maxSummaryLen = 6000 // Cap total summary (~1.5k tokens) var summaryParts []string 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..d7f9d28 100644 --- a/internal/brief/brief.go +++ b/internal/brief/brief.go @@ -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/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 a14bf92..8665bc2 100644 --- a/internal/config/prompts.go +++ b/internal/config/prompts.go @@ -496,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. +**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). -✅ 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 - -❌ 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}} @@ -534,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 } ` @@ -547,27 +536,16 @@ Your input is an "Enriched Goal" and relevant context from the project knowledge Your job is to decompose this goal into a sequential list of actionable execution tasks. **CRITICAL - Task Count:** -Scale the number of tasks to the actual complexity of the goal. Do NOT over-decompose. -- Simple change (fix a bug, rename, add a field): 1 task -- Small feature (new endpoint, new component): 1-2 tasks -- Medium feature (new service, new page with backend): 2-4 tasks -- Large feature (new subsystem, major refactor): 4-6 tasks -- System-wide change (migration, architecture shift): 5-8 tasks - -NEVER generate more tasks than the goal actually requires. If you can do it in 1 task, use 1 task. +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"). -2. **Self-Contained Context**: Each task MUST include enough context in its description to be executed independently by any AI coding agent, even without seeing the full plan. Include: - - Which files to create or modify - - Which existing patterns/conventions to follow (from the Knowledge Graph) - - Relevant constraints that apply - - The tech stack and libraries to use -3. **Dependencies**: Respect logical order. A task cannot rely on something not yet built. -4. **Context Aware**: Use the provided Knowledge Graph Context. Link tasks to existing Features/Patterns if mentioned. -5. **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. -6. **Verification**: For each task, define clear acceptance criteria and a validation command (e.g., "go test ./..."). -7. **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. When a caller provides an explicit tasks array, use those tasks directly instead of generating new ones. +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. **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}} @@ -578,79 +556,60 @@ NEVER generate more tasks than the goal actually requires. If you can do it in 1 "tasks": [ { "title": "Task Title", - "description": "DETAILED step-by-step instructions including file paths, patterns to follow, constraints to respect, and tech stack context. Must be self-contained enough for an independent AI agent to execute.", + "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}} diff --git a/internal/freshness/freshness_test.go b/internal/freshness/freshness_test.go index d03ad79..23575ee 100644 --- a/internal/freshness/freshness_test.go +++ b/internal/freshness/freshness_test.go @@ -170,15 +170,15 @@ func TestDecaySmoothCurve(t *testing.T) { } tests := []struct { - present []string - missing []string - wantMin float64 - wantMax float64 + 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", "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 + {nil, []string{"gone1.go", "gone2.go", "gone3.go", "gone4.go"}, 0.15, 0.25}, // 4/4 missing: 0.2 } for _, tt := range tests { 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/context.go b/internal/knowledge/context.go new file mode 100644 index 0000000..b4b4da5 --- /dev/null +++ b/internal/knowledge/context.go @@ -0,0 +1,307 @@ +// 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 && memoryBasePath != "" { + archPath := filepath.Join(memoryBasePath, "..", "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)) + } + if err == nil && 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 a1909c7..4a9fd4c 100644 --- a/internal/llm/models.go +++ b/internal/llm/models.go @@ -564,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) { @@ -635,8 +621,8 @@ type ModelOption struct { // 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) + option ModelOption + registryIdx int // Position in ModelRegistry (newest first per provider) } // GetModelsForProvider returns available models for a provider (for UI selection). 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/mcp/presenter.go b/internal/mcp/presenter.go index f37fc43..57b27e4 100644 --- a/internal/mcp/presenter.go +++ b/internal/mcp/presenter.go @@ -631,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 0c9cb81..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 { 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_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 058dd3d..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 { @@ -48,7 +49,7 @@ func renderNodeListInternal(nodes []memory.Node, verbose bool) { if verbose { renderVerboseTable(byType, typeOrder) } else { - renderGroupedList(byType, typeOrder, showWorkspace) + renderGroupedList(byType, typeOrder, showWorkspace, basePath) } } @@ -81,7 +82,7 @@ func renderHeader(byType map[string][]memory.Node, typeOrder []string, total int // 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) { +func renderGroupedList(byType map[string][]memory.Node, typeOrder []string, showWorkspace bool, basePath string) { termWidth := GetTerminalWidth() // 6 = 4 indent + 2 safety margin maxSummaryWidth := termWidth - 6 @@ -94,7 +95,8 @@ func renderGroupedList(byType map[string][]memory.Node, typeOrder []string, show sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorText) itemStyle := lipgloss.NewStyle().Foreground(ColorText) - itemStyleAlt := lipgloss.NewStyle().Foreground(ColorDim) + indexStyle := lipgloss.NewStyle().Foreground(ColorDim) + staleStyle := lipgloss.NewStyle().Foreground(ColorWarning) wsStyle := lipgloss.NewStyle().Foreground(ColorDim) for _, t := range typeOrder { @@ -108,30 +110,43 @@ func renderGroupedList(byType map[string][]memory.Node, typeOrder []string, show label := fmt.Sprintf("%s (%d)", utils.ToTitle(typePlural(t, len(groupNodes))), len(groupNodes)) fmt.Printf(" %s %s\n", badge, sectionStyle.Render(label)) - // Items + // Items with numbered indices for scanability for i, n := range groupNodes { summary := n.Summary if summary == "" { - summary = utils.Truncate(n.Text(), maxSummaryWidth) + summary = utils.Truncate(n.Text(), maxSummaryWidth-4) } - if lipgloss.Width(summary) > maxSummaryWidth { - summary = truncateToWidth(summary, maxSummaryWidth) + + // 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]") + } } - // Alternating styles for readability - style := itemStyle - if i%2 == 1 { - style = itemStyleAlt + // 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) + } + + idx := indexStyle.Render(fmt.Sprintf("%d.", i+1)) if showWorkspace { ws := n.Workspace if ws == "" { ws = "root" } - fmt.Printf(" %s %s\n", style.Render(padRight(summary, maxSummaryWidth)), wsStyle.Render(ws)) + fmt.Printf(" %s %s%s %s\n", idx, itemStyle.Render(padRight(summary, availWidth)), staleTag, wsStyle.Render(ws)) } else { - fmt.Printf(" %s\n", style.Render(summary)) + fmt.Printf(" %s %s%s\n", idx, itemStyle.Render(summary), staleTag) } } fmt.Println() 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/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 index 44fb1f9..9b26d1c 100644 --- a/skills/skills.go +++ b/skills/skills.go @@ -3,7 +3,7 @@ // 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) -// - CLI output (taskwing slash for non-Claude-Code tools) +// - MCP tool responses (for non-Claude-Code tools) package skills import ( diff --git a/skills/taskwing-context/SKILL.md b/skills/taskwing-context/SKILL.md new file mode 100644 index 0000000..09f9f8b --- /dev/null +++ b/skills/taskwing-context/SKILL.md @@ -0,0 +1,36 @@ +--- +name: taskwing-context +description: Dump full project knowledge into the conversation for complete architectural context. +--- + +# Project Context Dump + +Inject the complete project knowledge base into this conversation so you have full architectural context. + +## 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?" + +## 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 + +## 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-debug/SKILL.md b/skills/taskwing-debug/SKILL.md deleted file mode 100644 index 98921e7..0000000 --- a/skills/taskwing-debug/SKILL.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -name: taskwing-debug -description: Use when an issue requires root-cause-first debugging before proposing fixes. -argument-hint: "[problem description]" ---- - -# 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 -``` diff --git a/skills/taskwing-next/SKILL.md b/skills/taskwing-next/SKILL.md index 6043ecd..be13fa1 100644 --- a/skills/taskwing-next/SKILL.md +++ b/skills/taskwing-next/SKILL.md @@ -29,7 +29,7 @@ Extract from the response: - acceptance_criteria - suggested_ask_queries -If no task returned, inform user: "No pending tasks. Use 'taskwing plan list' to check plan status." +If no task returned, inform user: "No pending tasks. Use /taskwing:status to check plan status." ## Step 2: Fetch Scope-Relevant Context Call MCP tool `ask` with query based on task scope: @@ -112,5 +112,5 @@ Proceed with the task, following the patterns and respecting the constraints sho ```bash taskwing task list # List all tasks taskwing task list --status pending # Identify next pending task -taskwing plan status # Check active plan progress ``` +Use /taskwing:status to check active plan progress. diff --git a/skills/taskwing-plan/SKILL.md b/skills/taskwing-plan/SKILL.md index e02dda2..2a1fa67 100644 --- a/skills/taskwing-plan/SKILL.md +++ b/skills/taskwing-plan/SKILL.md @@ -54,10 +54,12 @@ Extract: clarify_session_id, questions, goal_summary, enriched_goal, is_ready_to ## 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 the questions to the user. Wait for user response. +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":** +**If user says "auto" or "skip":** Call `plan` again with action `clarify`, clarify_session_id, and auto_answer: true. **If user provides answers:** @@ -112,12 +114,12 @@ PLAN CREATED: [plan_id] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Plan saved and set as active. - -**Next steps:** -- Run /taskwing:next to start working on the first task ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` +**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) @@ -135,7 +137,11 @@ Call MCP tool `plan` with action=clarify: {"action": "clarify", "goal": "[goal from Step 1]", "mode": "interactive"} ``` -Ask clarifying questions until is_ready_to_plan is true. +**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. @@ -249,17 +255,13 @@ PLAN FINALIZED: [plan_id] **Total:** [N] phases, [M] tasks ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Plan saved and set as active. - -**Next steps:** -- Run /taskwing:next to start working on the first task ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` +**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) -```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 -``` +Use /taskwing:plan in your AI tool to create and manage plans. diff --git a/skills/taskwing-simplify/SKILL.md b/skills/taskwing-simplify/SKILL.md deleted file mode 100644 index 900dc74..0000000 --- a/skills/taskwing-simplify/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: taskwing-simplify -description: Use when you want to simplify code while preserving behavior. -argument-hint: "[file_path or paste code]" ---- - -# 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 -``` diff --git a/skills/taskwing-status/SKILL.md b/skills/taskwing-status/SKILL.md index fe180aa..cb48980 100644 --- a/skills/taskwing-status/SKILL.md +++ b/skills/taskwing-status/SKILL.md @@ -45,5 +45,5 @@ Commands: ## Fallback (No MCP) ```bash taskwing task list --status in_progress -taskwing plan list ``` +Use /taskwing:plan to manage plans via MCP.