Skip to content

Commit 5074fdf

Browse files
committed
feat(storage): centralize all project storage in ~/.taskwing/
Move all project data (memory.db, config, policies, activity logs, backups) from local {project}/.taskwing/ to ~/.taskwing/projects/<slug>/. Projects identified by SHA256-based slugs with symlink resolution. Key changes: - Add ProjectSlug(), GetProjectStorePath(), RegisterProject() in paths.go - Rewrite GetMemoryBasePath() to resolve via global store - Add tiered MCP context dumps (summary default, detail=full paginated) - Add global knowledge DB with union queries across project+global - Add config layering: global > profile > project via Viper merge - Remove .taskwing as project marker, remove MarkerTaskWing enum - Remove legacy backward-compat stubs and fallbacks - Fix all local .taskwing path references across 37 files
1 parent 45b9a42 commit 5074fdf

38 files changed

Lines changed: 865 additions & 319 deletions

cmd/bootstrap.go

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,6 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
9494
return fmt.Errorf("get current directory: %w", err)
9595
}
9696

97-
// Track user input for crash logging
98-
config.SetLastInput(fmt.Sprintf("bootstrap (skip-analyze=%v, dir=%s)", flags.SkipAnalyze, cwd))
99-
10097
// Debug mode: dump diagnostic info early
10198
if flags.Debug {
10299
fmt.Fprintln(os.Stderr, "")
@@ -192,8 +189,12 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
192189
}
193190
}
194191

195-
// Initialize Service
196-
svc := bootstrap.NewService(cwd, llmCfg)
192+
// Initialize Service with global store path
193+
storePath, err := config.GetProjectStorePath(cwd)
194+
if err != nil {
195+
return fmt.Errorf("resolve project store: %w", err)
196+
}
197+
svc := bootstrap.NewService(cwd, storePath, llmCfg)
197198
svc.SetVersion(version)
198199

199200
// Prompt for repo selection in multi-repo workspaces.
@@ -288,12 +289,6 @@ func executeAction(ctx context.Context, action bootstrap.Action, svc *bootstrap.
288289
if err := executeInitProject(svc, flags, plan); err != nil {
289290
return err
290291
}
291-
// Re-detect project context now that local .taskwing/ exists.
292-
// Without this, the cached context still points to ~/.taskwing/ (HOME)
293-
// and all subsequent DB operations write to the wrong database.
294-
if freshCtx, err := project.Detect(cwd); err == nil {
295-
_ = config.SetProjectContext(freshCtx)
296-
}
297292
return nil
298293

299294
case bootstrap.ActionGenerateAIConfigs:
@@ -795,7 +790,11 @@ func setupTrace(stream *core.StreamingOutput, trace bool, traceFile string, trac
795790
// Enable full payload capture so trace includes LLM messages and responses
796791
stream.SetIncludePayloads(true)
797792
if traceFile == "" {
798-
traceFile = filepath.Join(cwd, ".taskwing", "logs", "bootstrap.trace.jsonl")
793+
if sp, err := config.GetProjectStorePath(cwd); err == nil {
794+
traceFile = filepath.Join(sp, "bootstrap.trace.jsonl")
795+
} else {
796+
traceFile = filepath.Join(cwd, "bootstrap.trace.jsonl")
797+
}
799798
}
800799
var out *os.File
801800
var cleanup func()

cmd/cli_helpers.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"bufio"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"os"
89
"strings"
@@ -81,7 +82,10 @@ func isMissingProjectMemoryError(err error) bool {
8182
if err == nil {
8283
return false
8384
}
84-
return strings.Contains(err.Error(), "no .taskwing directory found at project root")
85+
errStr := err.Error()
86+
return errors.Is(err, config.ErrProjectContextNotSet) ||
87+
strings.Contains(errStr, "no project marker found") ||
88+
strings.Contains(errStr, "no project detected")
8589
}
8690

8791
func confirmOrAbort(prompt string) bool {

cmd/config.go

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,29 +33,61 @@ func initConfig() {
3333
// This finds the nearest .taskwing, go.mod, package.json, etc.
3434
projectCtx := detectProjectRoot()
3535

36-
// ALWAYS add global config path first (highest priority for user settings)
37-
viper.AddConfigPath(filepath.Join(home, ".taskwing"))
36+
// Layered config loading: global first, then project merges on top.
37+
// Resolution order: project > profile > global > env vars > defaults
38+
viper.SetConfigType("yaml")
3839

39-
// Add detected project root's .taskwing directory for project-specific config
40-
if projectCtx != nil && projectCtx.RootPath != "" {
41-
projectConfigPath := filepath.Join(projectCtx.RootPath, ".taskwing")
42-
if info, err := os.Stat(projectConfigPath); err == nil && info.IsDir() {
43-
viper.AddConfigPath(projectConfigPath)
40+
// 1. Load global config as base layer
41+
globalConfigFile := filepath.Join(home, ".taskwing", "config.yaml")
42+
if _, err := os.Stat(globalConfigFile); err == nil {
43+
viper.SetConfigFile(globalConfigFile)
44+
if err := viper.ReadInConfig(); err == nil {
45+
if viper.GetBool("verbose") && !viper.GetBool("json") {
46+
fmt.Fprintln(os.Stderr, "Loaded global config:", globalConfigFile)
47+
}
4448
}
4549
}
4650

47-
// Legacy: Also check CWD's .taskwing directory (for backwards compatibility)
48-
if _, err := os.Stat(".taskwing"); !os.IsNotExist(err) {
49-
viper.AddConfigPath(".taskwing")
51+
// 2. Load profile config (merges on top of global)
52+
// Check env var first, then scan os.Args for --profile flag
53+
// (Cobra hasn't parsed flags yet when initConfig runs)
54+
profileName := os.Getenv("TASKWING_PROFILE")
55+
if profileName == "" {
56+
profileName = viper.GetString("profile") // from global config.yaml
57+
}
58+
if profileName == "" {
59+
profileName = scanFlagFromArgs("profile")
60+
}
61+
if profileName != "" && filepath.Base(profileName) == profileName && !strings.Contains(profileName, "..") {
62+
profileFile := filepath.Join(home, ".taskwing", "profiles", profileName+".yaml")
63+
if _, err := os.Stat(profileFile); err == nil {
64+
profileViper := viper.New()
65+
profileViper.SetConfigFile(profileFile)
66+
if err := profileViper.ReadInConfig(); err == nil {
67+
if err := viper.MergeConfigMap(profileViper.AllSettings()); err == nil {
68+
if viper.GetBool("verbose") && !viper.GetBool("json") {
69+
fmt.Fprintln(os.Stderr, "Loaded profile config:", profileFile)
70+
}
71+
}
72+
}
73+
}
5074
}
5175

52-
viper.SetConfigName("config") // looks for config.yaml
53-
viper.SetConfigType("yaml")
54-
55-
// Attempt to read the configuration file
56-
if err := viper.ReadInConfig(); err == nil {
57-
if viper.GetBool("verbose") && !viper.GetBool("json") {
58-
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
76+
// 3. Load project config from global store (merges on top, highest file-based priority)
77+
if projectCtx != nil && projectCtx.RootPath != "" {
78+
if storePath, err := config.GetProjectStorePath(projectCtx.RootPath); err == nil {
79+
projectConfigFile := filepath.Join(storePath, "config.yaml")
80+
if _, err := os.Stat(projectConfigFile); err == nil {
81+
projectViper := viper.New()
82+
projectViper.SetConfigFile(projectConfigFile)
83+
if err := projectViper.ReadInConfig(); err == nil {
84+
if err := viper.MergeConfigMap(projectViper.AllSettings()); err == nil {
85+
if viper.GetBool("verbose") && !viper.GetBool("json") {
86+
fmt.Fprintln(os.Stderr, "Loaded project config:", projectConfigFile)
87+
}
88+
}
89+
}
90+
}
5991
}
6092
}
6193

@@ -66,11 +98,8 @@ func initConfig() {
6698
viper.SetDefault("preview", false)
6799

68100
// Memory store path: do NOT set a default here.
69-
// GetMemoryBasePath() has FAIL-FAST semantics:
70-
// 1. If user sets memory.path in config → use that
71-
// 2. Detected project root → use {project_root}/.taskwing/memory
72-
// 3. Otherwise → return error (no silent fallbacks)
73-
// For non-project commands (help, version), GetMemoryBasePathOrGlobal() provides ~/.taskwing fallback.
101+
// GetMemoryBasePath() resolves to ~/.taskwing/projects/<slug>/ via GetProjectStorePath.
102+
// For non-project commands (help, version), GetMemoryBasePathOrGlobal() provides fallback.
74103

75104
// LLM defaults (for bootstrap scanner)
76105
// Do NOT set defaults for llm.provider, llm.apiKey, or llm.model
@@ -115,3 +144,18 @@ func detectProjectRoot() *project.Context {
115144

116145
return ctx
117146
}
147+
148+
// scanFlagFromArgs extracts a flag value from os.Args before Cobra parses them.
149+
// Supports --flag=value and --flag value forms.
150+
func scanFlagFromArgs(name string) string {
151+
prefix := "--" + name
152+
for i, arg := range os.Args {
153+
if arg == prefix && i+1 < len(os.Args) {
154+
return os.Args[i+1]
155+
}
156+
if val, ok := strings.CutPrefix(arg, prefix+"="); ok {
157+
return val
158+
}
159+
}
160+
return ""
161+
}

cmd/doctor.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212

1313
"github.com/josephgoksu/TaskWing/internal/bootstrap"
14+
"github.com/josephgoksu/TaskWing/internal/config"
1415
"github.com/josephgoksu/TaskWing/internal/task"
1516
"github.com/josephgoksu/TaskWing/internal/ui"
1617
"github.com/spf13/cobra"
@@ -403,31 +404,30 @@ func applyRepairPrimitive(primitive, aiName, cwd, binPath string, init *bootstra
403404
}
404405

405406
func checkTaskWingInit(cwd string) DoctorCheck {
406-
taskwingDir := filepath.Join(cwd, ".taskwing")
407-
memoryDir := filepath.Join(taskwingDir, "memory")
408-
409-
if _, err := os.Stat(taskwingDir); os.IsNotExist(err) {
407+
storePath, err := config.GetProjectStorePath(cwd)
408+
if err != nil {
410409
return DoctorCheck{
411410
Name: "Initialization",
412411
Status: "fail",
413-
Message: "Not initialized",
412+
Message: "Cannot resolve project store",
414413
Hint: "Run: taskwing bootstrap",
415414
}
416415
}
417416

418-
if _, err := os.Stat(memoryDir); os.IsNotExist(err) {
417+
dbPath := filepath.Join(storePath, "memory.db")
418+
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
419419
return DoctorCheck{
420420
Name: "Initialization",
421421
Status: "warn",
422-
Message: "Partially initialized (missing memory/)",
422+
Message: fmt.Sprintf("Project store exists at %s but no memory.db", storePath),
423423
Hint: "Run: taskwing bootstrap",
424424
}
425425
}
426426

427427
return DoctorCheck{
428428
Name: "Initialization",
429429
Status: "ok",
430-
Message: ".taskwing/ directory exists",
430+
Message: fmt.Sprintf("Project store: %s", storePath),
431431
}
432432
}
433433

cmd/hook.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -637,12 +637,10 @@ func resolveHookMemoryPath() (string, error) {
637637
return memoryPath, nil
638638
}
639639

640-
// Claude hooks expose CLAUDE_PROJECT_DIR; use it before global fallback
641-
// to keep session state isolated per project even if project context wasn't set.
640+
// Claude hooks expose CLAUDE_PROJECT_DIR; use it to resolve the global project store.
642641
if projectDir := strings.TrimSpace(os.Getenv("CLAUDE_PROJECT_DIR")); projectDir != "" {
643-
taskwingDir := filepath.Join(projectDir, ".taskwing")
644-
if info, statErr := os.Stat(taskwingDir); statErr == nil && info.IsDir() {
645-
return filepath.Join(taskwingDir, "memory"), nil
642+
if storePath, err := config.GetProjectStorePath(projectDir); err == nil {
643+
return storePath, nil
646644
}
647645
}
648646

0 commit comments

Comments
 (0)