diff --git a/README.md b/README.md index 9af7ad1..90d9975 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Taskflow -Taskflow 是一个面向 AI 编程的多 Git 仓库 worktree 安全协调 CLI。它负责根据声明式配置创建或复用隔离 worktree、把准备好的多仓库工作区一次性交给 Codex 或 Claude,并安全清理 Taskflow 自己创建且登记过的任务资源。 +Taskflow 是一个面向 AI 编程的多 Git 仓库 worktree 安全协调 CLI。它负责根据声明式配置创建或复用隔离 worktree、为用户生成使用准备好工作区的原生 Codex 或 Claude 命令,并安全清理 Taskflow 自己创建且登记过的任务资源。 Taskflow 不管理需求、任务进度、AI session、提交、推送、PR、合并、发布或验证脚本。这些操作继续由用户和各仓库自己的流程负责。 @@ -10,10 +10,10 @@ Taskflow 不管理需求、任务进度、AI session、提交、推送、PR、 - 使用 Git worktree 隔离任务开发环境 - dry-run、全量 preflight、任务锁和 source/branch 锁 - 基于实时 Git 事实的幂等创建和中断后重试 -- 一条 `open` 命令将所有仓库关联到 Codex 或 Claude +- bundled skill 根据 taskflow.yaml 生成原生 Codex/Claude 命令,将所有仓库关联到工作区 - 基于 ownership manifest 的任务资源 dry-run 和安全清理 - 将 bundled Taskflow skill 安装到 Codex 或 Claude 的全局或项目级目录 -- 文本和 JSON 输出中的 create/reuse、冲突和 CLI 启动信息 +- 文本和 JSON 输出中的 create/reuse、冲突和清理 action 信息 ## 安装 @@ -58,7 +58,7 @@ taskflow skill install --project --tool claude ## 快速开始 -`--tasks-root` 默认是当前目录。仓库声明顺序必须稳定:第一个仓库是 `open` 的工作目录,后续仓库作为 additional directories。 +`--tasks-root` 默认是当前目录。仓库声明顺序必须稳定:第一个仓库是生成的 AI CLI 命令的工作目录,后续仓库作为 additional directories。 先预览,确认后执行: @@ -73,9 +73,13 @@ taskflow --tasks-root ~/tasks create REFUND-123 \ --repo payment-sdk=~/projects/payment-sdk \ --execute -taskflow --tasks-root ~/tasks open REFUND-123 -taskflow --tasks-root ~/tasks open REFUND-123 --tool claude -taskflow --tasks-root ~/tasks open REFUND-123 --tool codex -- --model gpt-5 +# execute 完成后,bundled skill 必须再次确认所有 worktree 为 reuse +taskflow --tasks-root ~/tasks create REFUND-123 --dry-run + +# 确认上一步所有 action 都是 reuse 后,生成并展示原生命令 +cd '/Users/me/tasks/REFUND-123/worktrees/order-service' +codex --add-dir '/Users/me/tasks/REFUND-123/worktrees/payment-sdk' \ + --add-dir '/Users/me/tasks/REFUND-123' --model gpt-5 taskflow --tasks-root ~/tasks delete REFUND-123 --dry-run taskflow --tasks-root ~/tasks delete REFUND-123 --execute @@ -83,7 +87,7 @@ taskflow --tasks-root ~/tasks delete REFUND-123 --execute `create` 没有 `--execute` 时默认是 dry-run。dry-run 不创建任务目录、taskflow.yaml、worktree、分支或锁目录;新任务的 execute 会在完整 preflight 后写入初始配置并创建缺失的 worktree。已有任务的 execute 只读取 taskflow.yaml 并创建或复用其中声明的 worktree;只有实际由 Taskflow 创建的 worktree 才会写入 ownership manifest。 -`open` 默认启动从 `PATH` 解析的 Codex。它使用第一个 worktree 作为 cwd,将后续 worktree 和任务根目录作为 additional directories。工具参数在 `--` 后原样透传,但 `--worktree` 和 `--worktree=...` 会被拒绝,以避免嵌套 worktree。匹配但 dirty 的 worktree 不会被拒绝。 +新任务先用带 `--repo` 的 dry-run 预览,用户批准后执行 create;execute 完成后,bundled skill 必须再次运行不带 `--repo` 的 `taskflow create --dry-run`,只有所有 repository 都报告 `reuse` 时才生成命令。已有任务也从这次不带 `--repo` 的 dry-run 开始。它使用第一个 worktree 作为 cwd,将后续 worktree 和任务根目录作为绝对路径 `--add-dir` 参数,并按用户目标 shell 进行安全引用和转义:POSIX shell 使用单引号,PowerShell 使用 `Set-Location -LiteralPath` 和 `$env:...`,cmd.exe 使用 `cd /d "..."` 和 `set "...=1"`。复杂 cmd 路径无法可靠转义时改用 PowerShell。命令由用户在自己的终端执行,匹配但 dirty 的 worktree 不会阻止生成。不要加入 `--worktree` 或 `--worktree=...`,避免嵌套 worktree。 ## 重试和修改配置 @@ -171,11 +175,11 @@ execute-mode create 会: 4. 对新任务通过 atomic write 写入初始 taskflow.yaml;已有任务不重写用户配置; 5. 只创建缺失的 worktree。 -任何 preflight 冲突都会在 Git mutation 前返回。Taskflow 的 ownership manifest 只记录由 Taskflow 实际创建的 worktree;结构匹配的手工 worktree 可以被 `open` 使用,但不会被 `delete` 清理。 +任何 preflight 冲突都会在 Git mutation 前返回。Taskflow 的 ownership manifest 只记录由 Taskflow 实际创建的 worktree;结构匹配的手工 worktree 可以被 `create` 复用,但不会被 `delete` 清理。 ## 破坏性兼容边界 -当前版本支持 create/open/delete、`skill install` 和当前 taskflow.yaml 配置。旧 `init/start/status/validate/repo add` 命令、旧字段、state/report/inventory 文件不在运行时兼容范围内。已有任务的 `create --repo` 追加调用也不再支持;请直接编辑 taskflow.yaml。没有 ownership.json 的旧任务不能由 `delete` 自动清理。 +当前版本支持 create/delete、`skill install` 和当前 taskflow.yaml 配置。旧 `init/start/status/validate/repo add` 命令、旧字段、state/report/inventory 文件不在运行时兼容范围内。已有任务的 `create --repo` 追加调用也不再支持;请直接编辑 taskflow.yaml。没有 ownership.json 的旧任务不能由 `delete` 自动清理。 ## 非目标 @@ -194,7 +198,7 @@ go test -race ./... go test ./cmd -run 'TestE2E' -count=1 ``` -`skill install` 属于发布集成命令,不参与任务工作区的 create/open/delete 生命周期。 +`skill install` 属于发布集成命令,不参与任务工作区的 create/delete 生命周期。 ## 许可证 diff --git a/cmd/e2e_safety_test.go b/cmd/e2e_safety_test.go index 9bf5328..d4046e7 100644 --- a/cmd/e2e_safety_test.go +++ b/cmd/e2e_safety_test.go @@ -7,7 +7,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "testing" @@ -46,7 +45,7 @@ func runE2E(t *testing.T, tasks string, args ...string) (string, error) { return output.String(), err } -func TestE2ECreateJSONAndOpenCLI(t *testing.T) { +func TestE2ECreateJSONAndReuseDirtyWorktree(t *testing.T) { repo1, repo2 := e2eGitRepo(t), e2eGitRepo(t) tasks := t.TempDir() preview, err := runE2E(t, tasks, "--json", "create", "FLOW", "--repo", "one="+repo1, "--repo", "two="+repo2, "--dry-run") @@ -69,18 +68,28 @@ func TestE2ECreateJSONAndOpenCLI(t *testing.T) { t.Fatalf("legacy file exists %s: %v", name, err) } } - toolDir := t.TempDir() - toolName, toolContents := "codex", []byte("#!/bin/sh\nexit 0\n") - if runtime.GOOS == "windows" { - toolName, toolContents = "codex.cmd", []byte("@echo off\r\nexit /b 0\r\n") - } - tool := filepath.Join(toolDir, toolName) - if err := os.WriteFile(tool, toolContents, 0755); err != nil { + if err := os.WriteFile(filepath.Join(root, "worktrees", "one", "dirty.txt"), []byte("dirty"), 0644); err != nil { t.Fatal(err) } - t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) - if output, err := runE2E(t, tasks, "--json", "open", "FLOW", "--tool", "codex", "--", "--model", "test"); err != nil { - t.Fatalf("open: %v: %s", err, output) + reuseOutput, err := runE2E(t, tasks, "--json", "create", "FLOW", "--dry-run") + if err != nil { + t.Fatalf("reuse dry-run: %v: %s", err, reuseOutput) + } + var reuse resultEnvelope + if err := json.Unmarshal([]byte(reuseOutput), &reuse); err != nil || !reuse.OK { + t.Fatalf("reuse dry-run JSON: %v %s", err, reuseOutput) + } + var reuseData struct { + Actions []struct { + Repo string `json:"repo"` + Status string `json:"status"` + } `json:"actions"` + } + if err := json.Unmarshal(reuse.Data, &reuseData); err != nil { + t.Fatalf("reuse data: %v", err) + } + if len(reuseData.Actions) != 2 || reuseData.Actions[0].Repo != "one" || reuseData.Actions[0].Status != "reuse" || reuseData.Actions[1].Repo != "two" || reuseData.Actions[1].Status != "reuse" { + t.Fatalf("reuse actions: %#v", reuseData.Actions) } if _, err := runE2E(t, tasks, "--json", "create", "FLOW", "--execute"); err != nil { t.Fatalf("repeat create: %v", err) diff --git a/cmd/root.go b/cmd/root.go index 3cb3e1b..b875cdf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,7 +27,7 @@ func NewRootCommand() *cobra.Command { var tasksRoot = "." var asJSON bool svc := app.New() - root := &cobra.Command{Use: "taskflow", Short: "Create Git worktrees and open AI coding tools", SilenceUsage: true} + root := &cobra.Command{Use: "taskflow", Short: "Create and manage Git worktrees for AI coding", SilenceUsage: true} root.PersistentFlags().StringVar(&tasksRoot, "tasks-root", ".", "task workspace root (default: current directory)") root.PersistentFlags().BoolVar(&asJSON, "json", false, "emit JSON") render := func(c *cobra.Command, r report.Result, code report.ExitCode) error { @@ -62,20 +62,6 @@ func NewRootCommand() *cobra.Command { create.Flags().BoolVar(&execute, "execute", false, "write initial taskflow.yaml and create missing worktrees") root.AddCommand(create) - var tool string - open := &cobra.Command{Use: "open [-- ...]", Args: cobra.MinimumNArgs(1), RunE: func(c *cobra.Command, args []string) error { - t, err := svc.Load(tasksRoot, args[0]) - if err != nil { - r := report.New("open", args[0]) - r.Fail(loadDiagnostic(err)) - return render(c, r, report.ExitConfig) - } - r, code := svc.Open(context.Background(), t, tool, args[1:], c.InOrStdin(), c.OutOrStdout(), c.ErrOrStderr()) - return render(c, r, code) - }} - open.Flags().StringVar(&tool, "tool", "", "codex or claude") - root.AddCommand(open) - var deleteDryRun, deleteExecute, deleteForce bool remove := &cobra.Command{Use: "delete ", Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { r, code := svc.Delete(context.Background(), app.DeleteOptions{ diff --git a/cmd/root_test.go b/cmd/root_test.go index e602893..8ef45b3 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -162,18 +162,18 @@ func TestSkillScope(t *testing.T) { } } -func TestPublicCommandsAreLimitedToCreateOpenVersionAndSkill(t *testing.T) { +func TestPublicCommandsAreLimitedToCreateDeleteVersionAndSkill(t *testing.T) { root := NewRootCommand() seen := map[string]bool{} for _, command := range root.Commands() { seen[command.Name()] = true } - for _, name := range []string{"create", "open", "version", "skill"} { + for _, name := range []string{"create", "delete", "version", "skill"} { if !seen[name] { t.Fatalf("missing public command %s", name) } } - for _, name := range []string{"init", "start", "status", "validate", "repo"} { + for _, name := range []string{"init", "start", "status", "validate", "repo", "open"} { if seen[name] { t.Fatalf("retired command still registered: %s", name) } diff --git a/internal/app/app.go b/internal/app/app.go index ff07739..661f939 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "io/fs" "os" "path/filepath" @@ -12,7 +11,6 @@ import ( "strings" "github.com/chenquan/taskflow/internal/config" - "github.com/chenquan/taskflow/internal/devtool" "github.com/chenquan/taskflow/internal/domain" "github.com/chenquan/taskflow/internal/execx" "github.com/chenquan/taskflow/internal/fsx" @@ -841,51 +839,3 @@ func samePath(a, b string) bool { bb, err := filepath.Abs(b) return err == nil && filepath.Clean(aa) == filepath.Clean(bb) } - -func (s Service) Open(ctx context.Context, t domain.Task, tool string, extraArgs []string, stdin io.Reader, stdout, stderr io.Writer) (report.Result, report.ExitCode) { - r := report.New("open", t.Task.ID) - if tool == "" { - tool = "codex" - } - if diagnostic, code := s.preflightOpen(ctx, t); diagnostic != nil { - r.Fail(*diagnostic) - return r, code - } - spec, err := devtool.AdapterImpl{Tool: tool}.Build(t, extraArgs) - if err != nil { - r.Fail(report.Diagnostic{Code: "INVALID_ARGUMENT", Message: err.Error()}) - return r, report.ExitConfig - } - resolved, err := s.Runner.LookPath(spec.Executable) - if err != nil || strings.TrimSpace(resolved) == "" { - r.Fail(report.Diagnostic{Code: "TOOL_NOT_FOUND", Message: fmt.Sprintf("%s executable was not found in PATH", tool)}) - return r, report.ExitEnvironment - } - spec.Executable = resolved - child, err := s.Runner.Run(ctx, execx.CommandSpec{Executable: spec.Executable, Args: spec.Args, Dir: spec.Dir, Stdin: stdin, Stdout: stdout, Stderr: stderr, Env: spec.Env}) - if err != nil { - r.Data = map[string]any{"tool": tool, "executable": spec.Executable, "childExitCode": child.ExitCode} - r.Fail(report.Diagnostic{Code: "TOOL_EXITED", Message: fmt.Sprintf("%s exited with code %d", tool, child.ExitCode)}) - return r, report.ExitExecution - } - r.Data = spec - return r, report.ExitOK -} - -func (s Service) preflightOpen(ctx context.Context, task domain.Task) (*report.Diagnostic, report.ExitCode) { - for _, repository := range task.Repositories { - sourceInfo, err := s.Git.Inspect(ctx, repository.Source) - if err != nil || sourceInfo.CommonDir == "" { - return &report.Diagnostic{Code: "NOT_GIT_REPOSITORY", Repo: repository.Name, Message: gitErrorMessage("inspect configured source", err)}, report.ExitEnvironment - } - target := filepath.Join(task.Task.Root, repository.Worktree) - targetInfo, err := s.Git.Inspect(ctx, target) - if err != nil { - return &report.Diagnostic{Code: "WORKTREE_INVALID", Repo: repository.Name, Message: err.Error()}, report.ExitConflict - } - if !samePath(targetInfo.CommonDir, sourceInfo.CommonDir) || targetInfo.Branch != repository.Branch { - return &report.Diagnostic{Code: "WORKTREE_MISMATCH", Repo: repository.Name, Message: fmt.Sprintf("worktree %s does not match source %s and branch %s", target, sourceInfo.CommonDir, repository.Branch)}, report.ExitConflict - } - } - return nil, report.ExitOK -} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 81c2987..38e8fc6 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -427,11 +427,13 @@ func TestGitErrorMessage(t *testing.T) { } } -type openRunner struct { - err error - lookPathErr error - calls int - spec execx.CommandSpec +func hasDiagnostic(diagnostics []report.Diagnostic, code string) bool { + for _, diagnostic := range diagnostics { + if diagnostic.Code == code { + return true + } + } + return false } type failSecondWorktreeRunner struct { @@ -448,10 +450,6 @@ func (r *failSecondWorktreeRunner) Run(ctx context.Context, spec execx.CommandSp return (execx.OSRunner{}).Run(ctx, spec) } -func (r *failSecondWorktreeRunner) LookPath(name string) (string, error) { - return (execx.OSRunner{}).LookPath(name) -} - func containsArg(args []string, expected string) bool { for _, arg := range args { if arg == expected { @@ -460,70 +458,3 @@ func containsArg(args []string, expected string) bool { } return false } - -func (r *openRunner) Run(_ context.Context, spec execx.CommandSpec) (execx.Result, error) { - r.calls++ - r.spec = spec - if r.err != nil { - return execx.Result{ExitCode: 7}, r.err - } - return execx.Result{}, nil -} - -func (r *openRunner) LookPath(name string) (string, error) { - if r.lookPathErr != nil { - return "", r.lookPathErr - } - return filepath.Join("/tools", name), nil -} - -func TestOpenUsesLiveIdentityAndAllowsDirtyWorktree(t *testing.T) { - repo1, repo2 := makeGitRepo(t), makeGitRepo(t) - tasks := t.TempDir() - service := New() - if _, code := service.Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "OPEN", Repositories: []string{"one=" + repo1, "two=" + repo2}, Execute: true}); code != report.ExitOK { - t.Fatal(code) - } - task, err := service.Load(tasks, "OPEN") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(task.Task.Root, task.Repositories[0].Worktree, "dirty.txt"), []byte("dirty"), 0644); err != nil { - t.Fatal(err) - } - runner := &openRunner{} - service.Runner = runner - result, code := service.Open(context.Background(), task, "", []string{"--model", "test"}, nil, nil, nil) - if code != report.ExitOK || !result.OK || runner.calls != 1 { - t.Fatalf("open: code=%d calls=%d result=%#v", code, runner.calls, result) - } - if runner.spec.Dir != filepath.Join(task.Task.Root, task.Repositories[0].Worktree) || runner.spec.Executable != filepath.Join("/tools", "codex") { - t.Fatalf("launch spec: %#v", runner.spec) - } - if !containsPair(runner.spec.Args, "--model", "test") { - t.Fatalf("extra args not forwarded: %#v", runner.spec.Args) - } - runner = &openRunner{err: errors.New("child failed")} - service.Runner = runner - if result, code := service.Open(context.Background(), task, "codex", nil, nil, nil, nil); code != report.ExitExecution || result.OK || !hasDiagnostic(result.Errors, "TOOL_EXITED") { - t.Fatalf("child failure: code=%d result=%#v", code, result) - } -} - -func containsPair(values []string, first, second string) bool { - for i := 0; i+1 < len(values); i++ { - if values[i] == first && values[i+1] == second { - return true - } - } - return false -} - -func hasDiagnostic(diagnostics []report.Diagnostic, code string) bool { - for _, diagnostic := range diagnostics { - if diagnostic.Code == code { - return true - } - } - return false -} diff --git a/internal/devtool/adapter.go b/internal/devtool/adapter.go deleted file mode 100644 index 36fc3d8..0000000 --- a/internal/devtool/adapter.go +++ /dev/null @@ -1,48 +0,0 @@ -package devtool - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/chenquan/taskflow/internal/domain" -) - -type LaunchSpec struct { - Executable, Dir string - Args, Env []string -} -type Adapter interface { - Build(domain.Task, []string) (LaunchSpec, error) -} -type AdapterImpl struct{ Tool string } - -func (a AdapterImpl) Build(t domain.Task, extraArgs []string) (LaunchSpec, error) { - if a.Tool != "codex" && a.Tool != "claude" { - return LaunchSpec{}, fmt.Errorf("unsupported tool %s", a.Tool) - } - if len(t.Repositories) == 0 { - return LaunchSpec{}, fmt.Errorf("task has no repositories") - } - primary := filepath.Join(t.Task.Root, t.Repositories[0].Worktree) - spec := LaunchSpec{Executable: a.Tool, Dir: primary} - for _, r := range t.Repositories[1:] { - p := filepath.Join(t.Task.Root, r.Worktree) - spec.Args = append(spec.Args, "--add-dir", p) - } - spec.Args = append(spec.Args, "--add-dir", t.Task.Root) - spec.Args = append(spec.Args, extraArgs...) - if a.Tool == "claude" { - spec.Env = []string{"CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1"} - } - for _, arg := range spec.Args { - if arg == "--worktree" || strings.HasPrefix(arg, "--worktree=") { - return LaunchSpec{}, fmt.Errorf("unsafe launch argument") - } - } - return spec, nil -} - -func ApplyEnv(base []string, overlay []string) []string { - return append(append([]string{}, base...), overlay...) -} diff --git a/internal/devtool/adapter_test.go b/internal/devtool/adapter_test.go deleted file mode 100644 index 331ae65..0000000 --- a/internal/devtool/adapter_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package devtool - -import ( - "path/filepath" - "reflect" - "strings" - "testing" - - "github.com/chenquan/taskflow/internal/domain" -) - -func TestLaunchSpecUsesBuiltInToolsAndRepositoryOrder(t *testing.T) { - root := filepath.FromSlash("/tmp/task") - task := domain.Task{Task: domain.TaskInfo{Root: root}, Repositories: []domain.Repository{{Name: "owner", Worktree: "worktrees/owner"}, {Name: "sdk", Worktree: "worktrees/sdk"}, {Name: "ui", Worktree: "worktrees/ui"}}} - - codex, err := (AdapterImpl{Tool: "codex"}).Build(task, nil) - if err != nil { - t.Fatal(err) - } - wantArgs := []string{"--add-dir", filepath.Join(root, "worktrees", "sdk"), "--add-dir", filepath.Join(root, "worktrees", "ui"), "--add-dir", root} - if codex.Executable != "codex" || codex.Dir != filepath.Join(root, "worktrees", "owner") || !reflect.DeepEqual(codex.Args, wantArgs) || len(codex.Env) != 0 { - t.Fatalf("codex: %#v", codex) - } - - claude, err := (AdapterImpl{Tool: "claude"}).Build(task, nil) - if err != nil { - t.Fatal(err) - } - if claude.Executable != "claude" || !strings.Contains(strings.Join(claude.Env, " "), "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1") { - t.Fatalf("claude: %#v", claude) - } -} - -func TestLaunchSpecRejectsUnsupportedToolsAndMissingRepositories(t *testing.T) { - root := filepath.FromSlash("/tmp/task") - if _, err := (AdapterImpl{Tool: "other"}).Build(domain.Task{Task: domain.TaskInfo{Root: root}, Repositories: []domain.Repository{{Name: "repo"}}}, nil); err == nil { - t.Fatal("expected unsupported tool error") - } - if _, err := (AdapterImpl{Tool: "codex"}).Build(domain.Task{Task: domain.TaskInfo{Root: root}}, nil); err == nil { - t.Fatal("expected missing repository error") - } -} - -func TestApplyEnvPreservesBaseAndOverlay(t *testing.T) { - base := []string{"A=1"} - got := ApplyEnv(base, []string{"B=2"}) - if !reflect.DeepEqual(got, []string{"A=1", "B=2"}) || len(base) != 1 { - t.Fatalf("base=%v got=%v", base, got) - } -} - -func TestBuildAppendsExtraArgsAndRejectsNestedWorktrees(t *testing.T) { - root := filepath.FromSlash("/tmp/task") - task := domain.Task{Task: domain.TaskInfo{Root: root}, Repositories: []domain.Repository{{Name: "repo", Worktree: "worktrees/repo"}}} - spec, err := AdapterImpl{Tool: "codex"}.Build(task, []string{"--model", "gpt"}) - if err != nil { - t.Fatal(err) - } - last := spec.Args[len(spec.Args)-2:] - if last[0] != "--model" || last[1] != "gpt" { - t.Fatalf("extra args not appended: %#v", spec.Args) - } - spec, err = (AdapterImpl{Tool: "codex"}).Build(task, []string{"--dangerously-skip-permissions"}) - if err != nil || spec.Args[len(spec.Args)-1] != "--dangerously-skip-permissions" { - t.Fatalf("permission argument should be forwarded: %#v, %v", spec, err) - } - for _, args := range [][]string{{"--worktree", "other"}, {"--worktree=other"}} { - if _, err := (AdapterImpl{Tool: "codex"}).Build(task, args); err == nil { - t.Fatalf("expected nested worktree argument %v to be rejected", args) - } - } -} diff --git a/internal/execx/runner.go b/internal/execx/runner.go index 3bf7cca..3ec2ed0 100644 --- a/internal/execx/runner.go +++ b/internal/execx/runner.go @@ -28,11 +28,9 @@ type Result struct { } type Runner interface { Run(context.Context, CommandSpec) (Result, error) - LookPath(string) (string, error) } type OSRunner struct{} -func (OSRunner) LookPath(name string) (string, error) { return exec.LookPath(name) } func (OSRunner) Run(ctx context.Context, s CommandSpec) (Result, error) { if s.Timeout > 0 { var cancel context.CancelFunc diff --git a/internal/execx/runner_test.go b/internal/execx/runner_test.go index 872f74e..1311a97 100644 --- a/internal/execx/runner_test.go +++ b/internal/execx/runner_test.go @@ -90,12 +90,3 @@ func TestMergeEnvironmentOverridesDuplicateKeys(t *testing.T) { t.Fatalf("windows=%v", windows) } } - -func TestLookPath(t *testing.T) { - if path, err := (OSRunner{}).LookPath("go"); err != nil || path == "" { - t.Fatalf("go path=%q err=%v", path, err) - } - if _, err := (OSRunner{}).LookPath("taskflow-command-that-does-not-exist"); err == nil { - t.Fatal("expected missing executable error") - } -} diff --git a/internal/ownership/ownership.go b/internal/ownership/ownership.go index bb06413..6c04211 100644 --- a/internal/ownership/ownership.go +++ b/internal/ownership/ownership.go @@ -13,7 +13,7 @@ const Version = 1 // Manifest records worktrees that Taskflow created itself. A configured // worktree without an entry is intentionally not considered safe to delete: -// it may have been created manually and merely adopted by open/create. +// it may have been created manually and merely adopted by create. type Manifest struct { Version int `json:"version"` TaskID string `json:"taskID"` diff --git a/openspec/changes/remove-open-command/.openspec.yaml b/openspec/changes/remove-open-command/.openspec.yaml new file mode 100644 index 0000000..7f2cf9b --- /dev/null +++ b/openspec/changes/remove-open-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-28 diff --git a/openspec/changes/remove-open-command/design.md b/openspec/changes/remove-open-command/design.md new file mode 100644 index 0000000..201fd20 --- /dev/null +++ b/openspec/changes/remove-open-command/design.md @@ -0,0 +1,57 @@ +## Context + +Taskflow currently exposes `taskflow open [--tool codex|claude] [-- ...]`, implemented by `cmd/root.go` (Cobra command), `Service.Open`/`preflightOpen` in `internal/app/app.go`, and `internal/devtool` (launch-spec builder). The command resolves the tool from `PATH`, runs a structural worktree preflight, assembles `--add-dir` arguments, injects `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` for Claude, rejects `--worktree` arguments, and streams the child process in the current terminal. + +The bundled skill (`skills/taskflow/SKILL.md`) is what agents actually follow. An agent can compose the equivalent native `claude`/`codex` command line from `taskflow.yaml` and hand it to the user, which removes the need for a Go launch path entirely. Eleven main specs reference `open`, so this is a cross-cutting contract change. + +## Goals / Non-Goals + +**Goals:** +- Remove the `open` command, `Service.Open`, `preflightOpen`, and the `internal/devtool` package. +- Move tool-launch knowledge (cwd selection, `--add-dir` assembly, Claude env var, `--worktree` warning) into the skill as guidance for composing native commands that the user executes. +- Preserve the "launch only from a structurally ready workspace" semantic by gating the composed command on `create --dry-run` reporting every repository as `reuse`. +- Update all eleven affected specs, README, and tests. + +**Non-Goals:** +- No new CLI command replaces `open` (no `taskflow claude`/`taskflow codex`). +- No compatibility or deprecation period for `open`; this project consistently ships breaking CLI changes documented in the README. +- No runtime enforcement of composed command safety (no `--worktree` rejection code, no PATH checks) — the user's own shell surfaces those errors. +- No changes to create, delete, lock, ownership, or configuration behavior. + +## Decisions + +### 1. Delete the entire launch path rather than keep it internal + +`devtool` is only referenced by `Service.Open`; keeping it as internal-only code would leave an uncalled module. Delete `cmd/root.go` open block, `Service.Open`, `preflightOpen`, and `internal/devtool/` wholesale. + +### 2. Skill composes the command; the user executes it + +The skill instructs the agent to read `taskflow.yaml` and present a ready-to-paste command instead of exec-ing the tool. Rationale: an agent shell typically has no PTY for an interactive TUI, and the user may want to add model/permission flags before launching. The skill first identifies the user's shell and quotes/escapes every path for POSIX shell, PowerShell, or cmd.exe; it composes: + +- `cd /worktrees/` (first repository stays the cwd/primary) +- `claude --add-dir ... --add-dir ` prefixed with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` +- `codex --add-dir ... --add-dir ` +- absolute paths only, so the pasted command works from any directory; shell-specific quoting keeps spaces and metacharacters from changing the command + +### 3. `create --dry-run` is the readiness gate + +`preflightOpen`'s checks (target exists, belongs to source common dir, expected branch; dirty is acceptable) are a subset of what create's reuse classification already verifies. For a new task, the skill first previews the requested repositories, obtains execute approval, runs create, and then runs `taskflow create --dry-run` without `--repo`; only when this post-create check reports `reuse` for every repository (no `create`, no conflict) should the agent compose and present the tool command. Existing tasks start with the same no-`--repo` check. Zero new Go code; the "structurally ready" requirement keeps a deterministic check. + +### 4. Safety notes degrade from runtime enforcement to skill guidance + +`--worktree`/`--worktree=...` rejection and `TOOL_NOT_FOUND` handling disappear with the launch path. The skill keeps the `--worktree` warning as text (the skill content test already asserts it). A missing tool binary is reported by the user's own shell, so no diagnostic replaces `TOOL_NOT_FOUND`. + +### 5. Spec cleanup rides the same delta + +While rewriting `cli-output-contract`'s exit-code requirement, drop "1 for ... a launched child process that exits non-zero" and "6 for external-tool incompatibility" — both describe the removed launch path (the `ExitToolCompatibility` constant is pre-existing dead code and stays untouched). + +## Risks / Trade-offs + +- [Readiness gate is advisory] An agent may skip the `create --dry-run` step and compose a command for a broken workspace → the skill text makes the dry-run gate the first instruction of the launch flow; the launched tool itself will also surface a bad worktree. +- [Composed commands are unvalidated] A wrong path or typo'd flag in the generated command is no longer caught by Go code → absolute-path rule in the skill; the user reviews the command before running it. +- [Eleven-spec blast radius] Deltas touch many requirement texts → most edits are sentence-level removals; `development-tool-sessions`, `taskflow-multirepo-skill`, and `readiness-and-initialization-integrity` carry the substantive rewrites. +- [Breaking change for existing users] `taskflow open` invocations fail with "unknown command" → README documents the native-command replacement; the skill (re)installation path ships the new guidance. + +## Migration Plan + +No runtime migration. Users replace `taskflow open [--tool ...]` with the skill-composed native command; README shows the exact mapping. Rollback is a git revert; no persisted state is affected. diff --git a/openspec/changes/remove-open-command/proposal.md b/openspec/changes/remove-open-command/proposal.md new file mode 100644 index 0000000..05ed4db --- /dev/null +++ b/openspec/changes/remove-open-command/proposal.md @@ -0,0 +1,38 @@ +## Why + +The `open` command was designed as a fast path to launch Codex or Claude from a prepared workspace, but the bundled skill can guide an agent to generate the native `claude`/`codex` command line for the user to run directly. Wrapping tool launch in Go code adds CLI surface, launch diagnostics, and a preflight path that duplicate what the skill and `create --dry-run` already provide. Removing it keeps the CLI focused on the workspace lifecycle (create/delete) and moves "open a terminal" back to skill guidance. + +## What Changes + +- **BREAKING** Remove the `taskflow open` CLI command, its `--tool` flag, and the `TOOL_NOT_FOUND`/`TOOL_EXITED` diagnostics. +- **BREAKING** Remove `Service.Open`, `preflightOpen`, and the `internal/devtool` package; Taskflow no longer builds, validates, or execs tool launch specifications. +- Rewrite the bundled skill's "打开 CLI" guidance: for a new task, preview with `--repo`, obtain approval, execute create, then run a no-`--repo` `create --dry-run`; after every repository reports `reuse`, the agent reads `taskflow.yaml`, composes a shell-appropriate native command line (first repository worktree as cwd, `--add-dir` for later worktrees and the task root, `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` for Claude, safely quoted absolute paths), and presents it for the user to execute. +- Keep the `--worktree`/`--worktree=...` warning as skill-level guidance only; there is no runtime enforcement after removal. +- Update README and the skill content test to match the new guidance. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `development-tool-sessions`: Rewrite from "CLI builds and execs launch specifications" to "the skill composes native `claude`/`codex` command lines for the user to run"; remove CLI launch, PATH resolution, env injection, and argument-filtering requirements. +- `taskflow-multirepo-skill`: Replace `taskflow open` invocations with native command-line generation guidance gated on the post-create no-`--repo` `create --dry-run` reuse status. +- `readiness-and-initialization-integrity`: Remove the Open source-identity requirement while retaining the create non-bare-source preflight. +- `cli-output-contract`: Supported operational commands become create and delete. +- `e2e-command-flow`: Remove open preflight and binary open scenarios; keep create/delete coverage. +- `reporting-validation-readiness`: Operational result reporting covers create and delete only. +- `cli-operational-safety`: Remove open-side worktree identity and dirty-launch requirements; keep create-side reuse checks. +- `worktree-reconciliation`: Remove the state-free open readiness gate requirement's launch scenarios; keep state-free reuse semantics for create. +- `cross-task-source-coordination`: Remove open from the non-contending read-only command list. +- `aggregate-status-validation`: Reference create/delete action facts instead of create/open/delete. +- `task-configuration-validation`: Drop "open uses the first repository as cwd" phrasing; configuration order still defines the skill's cwd. + +## Impact + +- Code: `cmd/root.go` (delete open command), `internal/app/app.go` (delete `Open`/`preflightOpen`), delete `internal/devtool/` package. +- Tests: `cmd/root_test.go` command whitelist, `cmd/e2e_safety_test.go` open e2e, `internal/app/app_test.go` open cases, `skills/skill_content_test.go` content assertions. +- Docs: `README.md` open sections, `skills/taskflow/SKILL.md` 打开 CLI chapter and failure list. +- Users: `taskflow open` invocations stop working; the skill instead prints the native command to run. diff --git a/openspec/changes/remove-open-command/specs/aggregate-status-validation/spec.md b/openspec/changes/remove-open-command/specs/aggregate-status-validation/spec.md new file mode 100644 index 0000000..39e4fce --- /dev/null +++ b/openspec/changes/remove-open-command/specs/aggregate-status-validation/spec.md @@ -0,0 +1,8 @@ +## MODIFIED Requirements + +### Requirement: Do not provide lifecycle reporting or validation +Taskflow MUST NOT provide status or validate commands, persist validation reports, or infer task readiness from historical state. Current create/delete action facts MUST be reported directly by those commands. + +#### Scenario: Removed reporting command +- **WHEN** a user invokes status or validate +- **THEN** the CLI rejects the retired command and leaves the task workspace unchanged diff --git a/openspec/changes/remove-open-command/specs/cli-operational-safety/spec.md b/openspec/changes/remove-open-command/specs/cli-operational-safety/spec.md new file mode 100644 index 0000000..6bf8f3d --- /dev/null +++ b/openspec/changes/remove-open-command/specs/cli-operational-safety/spec.md @@ -0,0 +1,21 @@ +## MODIFIED Requirements + +### Requirement: Contain task paths +The CLI MUST reject task IDs that are not a single safe path component before resolving configuration or writing task files. + +#### Scenario: Reject traversal task ID +- **WHEN** a user passes `../other-task` to create, delete, or any task-loading command +- **THEN** the command returns a configuration error and writes no file outside the tasks root + +### Requirement: Validate and report actual managed repositories +Create MUST inspect configured sources and targets as real Git worktrees, report whether each target will be created or reused, and surface current worktree identity conflicts. Dirty worktrees MUST not be rejected solely for being dirty. + +#### Scenario: Dirty managed worktree +- **WHEN** a configured worktree has an uncommitted file while source and branch identity remain valid +- **THEN** create reports the target as reusable + +## REMOVED Requirements + +### Requirement: Run interactive tools with streams and environment +**Reason**: The CLI no longer launches child tools; runner streaming and explicit environment overlays existed only for the tool launch path. +**Migration**: `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` is set as a prefix on the skill-composed `claude` command line that the user runs. diff --git a/openspec/changes/remove-open-command/specs/cli-output-contract/spec.md b/openspec/changes/remove-open-command/specs/cli-output-contract/spec.md new file mode 100644 index 0000000..9a5ab44 --- /dev/null +++ b/openspec/changes/remove-open-command/specs/cli-output-contract/spec.md @@ -0,0 +1,34 @@ +## MODIFIED Requirements + +### Requirement: Emit a stable machine-readable result envelope +Commands supporting `--json` MUST emit valid JSON with `schemaVersion`, `command`, `ok`, `taskID` when known, `data`, `warnings`, and structured `errors`. JSON output MUST contain no ANSI control sequences. The supported operational commands are create and delete. + +#### Scenario: Render a configuration failure as JSON +- **WHEN** create or delete encounters invalid configuration +- **THEN** it emits a result envelope with `ok: false` and at least one structured error code, message, and optional repository and hint + +#### Scenario: Render create action facts as JSON +- **WHEN** create runs in dry-run or execute mode +- **THEN** its data identifies the resolved configuration and each repository's create, reuse, or conflict action + +#### Scenario: Render delete action facts as JSON +- **WHEN** delete runs in dry-run or execute mode +- **THEN** its data identifies each owned worktree removal, local branch deletion, and task-directory cleanup action + +### Requirement: Map expected failures to stable exit codes +The CLI MUST use exit code 0 for success, 1 for execution failure, 2 for argument or configuration errors, 3 for environment-preflight failure, 4 for partial completion after Git mutation begins, and 5 for deterministic worktree/branch, task-lock, or source-branch-lock conflict. Validation failure is not a supported command result. + +#### Scenario: Return a lock conflict exit code +- **WHEN** create cannot acquire its task lock +- **THEN** it reports a lock conflict and exits with code 5 + +#### Scenario: Return a source-branch lock conflict exit code +- **WHEN** another local task holds the same source-repository branch lock +- **THEN** execute-mode create reports `SOURCE_BRANCH_LOCKED` and exits with code 5 before mutation + +### Requirement: Preserve facts across output modes +Text and JSON renderings of a create or delete result MUST express the same success state, warnings, errors, and operational action facts, even though their layouts differ. + +#### Scenario: Render equivalent command outcomes +- **WHEN** create or delete is rendered in text mode and JSON mode +- **THEN** both renderings express the same success state, warnings, structured errors, and action data diff --git a/openspec/changes/remove-open-command/specs/cross-task-source-coordination/spec.md b/openspec/changes/remove-open-command/specs/cross-task-source-coordination/spec.md new file mode 100644 index 0000000..f67d69a --- /dev/null +++ b/openspec/changes/remove-open-command/specs/cross-task-source-coordination/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: Coordinate execute-mode source branches across tasks +Before execute-mode create mutates taskflow.yaml or creates a worktree, the CLI MUST acquire a local exclusive lock for every configured `(canonical Git common directory, branch)` pair. Locks MUST be stored beneath that common directory, acquired in deterministic sorted order, and released after create returns. A held lock MUST return exit code 5 with a structured `SOURCE_BRANCH_LOCKED` diagnostic; a lock-storage failure MUST return an environment failure. Read-only dry-run commands and creates for different branches MUST NOT contend. + +#### Scenario: Competing tasks use the same source branch +- **WHEN** one task holds an execute-mode lock for a source repository branch +- **THEN** another task creating the same source branch exits with `SOURCE_BRANCH_LOCKED` before taskflow.yaml or Git mutation + +#### Scenario: Different source branches proceed independently +- **WHEN** two tasks target different branches in the same source repository +- **THEN** each task can acquire its own source-branch lock diff --git a/openspec/changes/remove-open-command/specs/development-tool-sessions/spec.md b/openspec/changes/remove-open-command/specs/development-tool-sessions/spec.md new file mode 100644 index 0000000..ff83718 --- /dev/null +++ b/openspec/changes/remove-open-command/specs/development-tool-sessions/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Compose native tool commands from skill guidance +The skill SHALL instruct an agent to compose ready-to-run native `claude` and `codex` command lines from taskflow.yaml for the user to execute, instead of launching tools through the CLI. The composed command MUST use the first repository worktree as the working directory, pass absolute additional-directory paths for every later repository worktree and the task root via `--add-dir`, and prefix Claude invocations with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1`. The agent MUST identify the user's shell and quote or escape every path using syntax valid for POSIX shells, PowerShell, or cmd.exe; it MUST present the command to the user rather than execute it, and MUST warn against `--worktree` and `--worktree=...` arguments. + +#### Scenario: Compose a Claude command +- **WHEN** every configured worktree reports `reuse` and the user requests Claude +- **THEN** the agent presents a command that changes into the first repository worktree, sets `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1`, invokes `claude`, and passes `--add-dir` with absolute paths for every later worktree and the task root + +#### Scenario: Compose a Codex command +- **WHEN** every configured worktree reports `reuse` and the user requests Codex +- **THEN** the agent presents a `codex` command with the same working directory and the same absolute `--add-dir` arguments + +#### Scenario: Gate composition on structural readiness +- **WHEN** the readiness `create --dry-run` without `--repo` reports any repository as `create` or a conflict +- **THEN** the agent surfaces the reported issue instead of composing a tool command + +#### Scenario: Warn against nested worktree flags +- **WHEN** the user requests `--worktree` or `--worktree=...` in the composed invocation +- **THEN** the agent omits or refuses the flag and explains the nested-worktree risk + +#### Scenario: Render shell-safe paths +- **WHEN** a task root or worktree path contains spaces or shell metacharacters +- **THEN** the agent renders a shell-appropriate quoted and escaped command for the user's POSIX shell, PowerShell, or cmd.exe instead of inserting the raw path + +## REMOVED Requirements + +### Requirement: Build safe Codex and Claude launch specifications +**Reason**: The CLI no longer builds, validates, or execs tool launch specifications; launch knowledge moves into the skill as native command-line composition guidance. +**Migration**: Run the skill-composed native `claude`/`codex` command instead of `taskflow open`. The `--add-dir` assembly, Claude environment variable, and user tool arguments are expressed directly in the composed command line. + +### Requirement: Launch only from a structurally ready workspace +**Reason**: With no CLI launch path there is no runtime preflight to enforce. +**Migration**: For new tasks, the skill runs the readiness `create --dry-run` without `--repo` after create execute, and gates command composition on every repository reporting `reuse`; existing tasks start with the same check. This performs the same live worktree identity checks; dirty matching worktrees remain launchable. diff --git a/openspec/changes/remove-open-command/specs/e2e-command-flow/spec.md b/openspec/changes/remove-open-command/specs/e2e-command-flow/spec.md new file mode 100644 index 0000000..534e4bf --- /dev/null +++ b/openspec/changes/remove-open-command/specs/e2e-command-flow/spec.md @@ -0,0 +1,55 @@ +## MODIFIED Requirements + +### Requirement: Exercise the complete CLI task lifecycle +The test suite SHALL execute the reduced user-facing flow through the Cobra command surface and SHALL verify each command's result, configuration, and relevant Git side effects. + +#### Scenario: In-process create preparation succeeds +- **WHEN** a test runs create --dry-run and create --execute against temporary valid repositories +- **THEN** dry-run reports actions without creating configuration or worktrees, and execute writes taskflow.yaml and ownership metadata and creates worktrees + +#### Scenario: Incomplete or dirty work is handled from live facts +- **WHEN** a configured target is missing or mismatched, or a matching worktree is dirty +- **THEN** create reports the missing or mismatched target as a planned creation or conflict and reports the dirty matching target as reuse + +#### Scenario: Dry-run preserves all managed state +- **WHEN** create dry-run is executed for a new or existing task +- **THEN** taskflow.yaml, lock artifacts, worktrees, branches, source checkout, and target directories remain unchanged + +#### Scenario: Repeated execute is idempotent +- **WHEN** create --execute is run twice for the same task +- **THEN** the second command succeeds, reuses every matching worktree, and creates no duplicate branch or directory + +#### Scenario: Direct configuration edits reconcile safely +- **WHEN** an existing taskflow.yaml is edited to add a repository and create is run without --repo +- **THEN** create preserves the existing configuration, creates only the newly declared missing worktree, and does not delete any unlisted worktree + +#### Scenario: Repository arguments on existing tasks are rejected +- **WHEN** an existing task receives a repository argument through create +- **THEN** the command returns CONFIG_EDIT_REQUIRED and leaves taskflow.yaml and Git state unchanged + +#### Scenario: Partial create retries without state +- **WHEN** worktree creation fails after an earlier repository succeeds and the fault is removed +- **THEN** the first command returns a partial failure without state.json and the retry reuses the earlier worktree before creating the remaining one + +#### Scenario: Concurrent create conflicts safely +- **WHEN** one create holds the task lock or source branch lock +- **THEN** a competing command returns the conflict exit code without corrupting taskflow.yaml or existing worktrees + +#### Scenario: Invalid requests preserve files +- **WHEN** an invalid task identifier, unknown YAML field, or mismatched target is supplied +- **THEN** the CLI returns the documented structured failure and preserves existing configuration and Git state + +### Requirement: Verify executable and rendering boundaries +The test suite SHALL invoke a built `taskflow` binary in addition to in-process Cobra commands and SHALL verify stable JSON/text output and process exit codes for create and delete. + +#### Scenario: Subprocess reports a configuration failure +- **WHEN** the built binary runs create or delete with an invalid task identifier in JSON mode +- **THEN** the process exits with the configuration exit code and emits a parseable result envelope with `ok: false` and a structured diagnostic + +#### Scenario: Successful output preserves command data +- **WHEN** create or delete runs without --json +- **THEN** its text output includes the action data needed to understand the result + +#### Scenario: Subprocess reduced flow succeeds +- **WHEN** the built binary executes create dry-run and execute against temporary repositories +- **THEN** its process exit codes, JSON envelopes, text reports, and Git worktree state match the create/delete contract diff --git a/openspec/changes/remove-open-command/specs/readiness-and-initialization-integrity/spec.md b/openspec/changes/remove-open-command/specs/readiness-and-initialization-integrity/spec.md new file mode 100644 index 0000000..bc497ac --- /dev/null +++ b/openspec/changes/remove-open-command/specs/readiness-and-initialization-integrity/spec.md @@ -0,0 +1,8 @@ +## MODIFIED Requirements + +### Requirement: Require a non-bare Git worktree source +Create preflight SHALL accept a repository source only when Git inspection reports a non-bare worktree. + +#### Scenario: Bare repository source +- **WHEN** a configured source is an existing bare Git repository +- **THEN** create rejects it before writing taskflow.yaml or creating a task workspace diff --git a/openspec/changes/remove-open-command/specs/reporting-validation-readiness/spec.md b/openspec/changes/remove-open-command/specs/reporting-validation-readiness/spec.md new file mode 100644 index 0000000..dcdee2b --- /dev/null +++ b/openspec/changes/remove-open-command/specs/reporting-validation-readiness/spec.md @@ -0,0 +1,18 @@ +## REMOVED Requirements + +### Requirement: Report create, open, and delete operational results +**Reason**: The `open` command is removed; launch results no longer exist as CLI output, so the reporting requirement covers create and delete only. +**Migration**: Native tool launches happen outside Taskflow; their output is the tool's own terminal output. + +## ADDED Requirements + +### Requirement: Report create and delete operational results +Create and delete SHALL expose their current action, conflict, and cleanup results through the common text and JSON output contract without validation or readiness history. + +#### Scenario: Report a create action +- **WHEN** create previews or executes a repository reconciliation +- **THEN** output includes the repository and its create, reuse, or failure result + +#### Scenario: Report a delete action +- **WHEN** delete previews or executes an ownership-checked cleanup +- **THEN** output includes each worktree, local branch, and task-directory action and its result diff --git a/openspec/changes/remove-open-command/specs/task-configuration-validation/spec.md b/openspec/changes/remove-open-command/specs/task-configuration-validation/spec.md new file mode 100644 index 0000000..6bfe1a6 --- /dev/null +++ b/openspec/changes/remove-open-command/specs/task-configuration-validation/spec.md @@ -0,0 +1,23 @@ +## MODIFIED Requirements + +### Requirement: Load a strict task configuration +The CLI MUST decode taskflow.yaml with unknown fields rejected, apply the current internal configuration version when omitted, normalize source paths to absolute paths, derive the task root from the selected task workspace, require task.id to match the selected task directory, preserve repository order, and use the first repository as primary. State, inventory, validation, execution, dependency, and check fields are outside the current configuration contract and MUST be rejected by strict decoding. + +#### Scenario: Reject an unknown YAML field +- **WHEN** a taskflow.yaml contains an unrecognized or retired field +- **THEN** task loading returns a configuration error identifying that field + +#### Scenario: Preserve repository order +- **WHEN** taskflow.yaml lists repositories in a specific order +- **THEN** loading preserves that order and the first repository remains the primary working directory for skill-composed tool commands + +### Requirement: Validate repository and dependency constraints +The CLI MUST require unique repository names matching the supported name pattern, existing source directories, non-empty base and branch values, worktree paths contained beneath the task worktrees directory, and unique target paths. Loading and structural validation MUST NOT launch external commands. + +#### Scenario: Reject a worktree path escape +- **WHEN** a repository worktree path resolves outside the task worktrees directory +- **THEN** task loading fails before any filesystem or Git mutation + +#### Scenario: Reject duplicate targets +- **WHEN** two repositories resolve to the same worktree target +- **THEN** configuration validation returns a conflict before create or delete diff --git a/openspec/changes/remove-open-command/specs/taskflow-multirepo-skill/spec.md b/openspec/changes/remove-open-command/specs/taskflow-multirepo-skill/spec.md new file mode 100644 index 0000000..51d28f0 --- /dev/null +++ b/openspec/changes/remove-open-command/specs/taskflow-multirepo-skill/spec.md @@ -0,0 +1,19 @@ +## MODIFIED Requirements + +### Requirement: Guide safe multi-repository orchestration +The skill SHALL instruct an agent to locate the task, inspect taskflow.yaml and ownership.json when cleanup is requested, use repeated `create --repo` only to bootstrap a task that has no taskflow.yaml, edit taskflow.yaml directly when the repository topology changes, use create/delete dry-run before execute, run a second create dry-run without `--repo` after create execute, and compose shell-appropriate native `claude`/`codex` command lines for the user only after that post-create dry-run confirms every configured worktree is structurally ready. It MUST NOT require state, inventory, validation, dependencies, repository roles, contract owners, or an append command. + +#### Scenario: Prepare multiple repositories +- **WHEN** an agent receives a Taskflow task with multiple repositories +- **THEN** it reports repository order, obtains explicit execute approval, runs create, runs create --dry-run without `--repo`, and composes the requested shell-appropriate native tool command only after every repository reports `reuse` + +#### Scenario: Add a repository to an existing task +- **WHEN** an agent needs another repository after taskflow.yaml already exists +- **THEN** it edits the desired repository list in taskflow.yaml, runs create --dry-run without --repo, and only runs create --execute after approval + +### Requirement: Keep deterministic actions in the CLI +The skill MUST instruct agents to use taskflow for workspace, ownership-checked cleanup, worktree, and lock mutations and MUST prohibit shell-composed replacements, implicit cleanup/push/PR actions, repository append arguments on existing tasks, and nested worktree flags in composed tool commands. Delete MUST require explicit execute mode and MUST refuse resources without matching ownership records. Other tool arguments requested by the user MUST be preserved in order after the composed `--add-dir` arguments and quoted or escaped for the selected shell. + +#### Scenario: User requests execution +- **WHEN** a user approves a prepared create plan +- **THEN** the agent invokes create execute, reports its machine-readable result, and presents the composed native tool command only when every worktree reports `reuse` diff --git a/openspec/changes/remove-open-command/specs/worktree-reconciliation/spec.md b/openspec/changes/remove-open-command/specs/worktree-reconciliation/spec.md new file mode 100644 index 0000000..bb56bf8 --- /dev/null +++ b/openspec/changes/remove-open-command/specs/worktree-reconciliation/spec.md @@ -0,0 +1,5 @@ +## REMOVED Requirements + +### Requirement: Use a state-free open readiness gate +**Reason**: The `open` command is removed, so there is no CLI launch gate to satisfy. State-free reconciliation semantics already live in create's reuse classification. +**Migration**: Run `create --dry-run` — it performs the same live worktree identity checks and reports each repository as `reuse` or a conflict. The skill composes the native tool command only when every repository reports `reuse`; structurally matching manually created worktrees remain reusable by create without an ownership marker, and dirty matching worktrees remain launchable. diff --git a/openspec/changes/remove-open-command/tasks.md b/openspec/changes/remove-open-command/tasks.md new file mode 100644 index 0000000..72d2f73 --- /dev/null +++ b/openspec/changes/remove-open-command/tasks.md @@ -0,0 +1,27 @@ +## 1. Remove the Go launch path + +- [x] 1.1 Delete the `open` command block and its `--tool` flag from `cmd/root.go` +- [x] 1.2 Delete `Service.Open` and `preflightOpen` from `internal/app/app.go` +- [x] 1.3 Delete the `internal/devtool` package (`adapter.go` and `adapter_test.go`) +- [x] 1.4 Verify `go build ./...` and `go vet ./...` pass with no orphaned references + +## 2. Update tests + +- [x] 2.1 Update the command whitelist test in `cmd/root_test.go` to expect create, delete, version, and skill only +- [x] 2.2 Rework the open e2e case in `cmd/e2e_safety_test.go` into a create --dry-run reuse assertion on a dirty matching worktree +- [x] 2.3 Remove the open launch cases from `internal/app/app_test.go` +- [x] 2.4 Run `go test ./...` and fix any remaining open references + +## 3. Rewrite the skill guidance + +- [x] 3.1 Rewrite the "打开 CLI" chapter of `skills/taskflow/SKILL.md`: after new-task create execute, gate on the no-`--repo` `create --dry-run` reporting every repository as `reuse`, then compose shell-appropriate native `claude`/`codex` commands with safely quoted absolute paths (first worktree as cwd, `--add-dir` paths for later worktrees and the task root, `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` prefix for Claude) and present them for the user to run; keep the `--worktree` warning +- [x] 3.2 Remove open references from the SKILL.md intro, the taskflow.yaml note, and the `TOOL_NOT_FOUND` entry in the failure list +- [x] 3.3 Update `skills/skill_content_test.go`: replace the `"open "` assertion with assertions for the composed native commands, shell-specific quoting, and the post-execute dry-run reuse gate + +## 4. Update documentation + +- [x] 4.1 Update `README.md`: remove open usage examples and behavior notes, document the skill-composed native command replacement, and adjust the create/open/delete lifecycle phrasing + +## 5. Final verification + +- [x] 5.1 Run `go test ./...`, `go vet ./...`, and `openspec validate remove-open-command`; confirm all pass diff --git a/skills/skill_content_test.go b/skills/skill_content_test.go index a031655..9d3e3b3 100644 --- a/skills/skill_content_test.go +++ b/skills/skill_content_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -func TestTaskflowSkillGuidesCreateOpenAndDelete(t *testing.T) { +func TestTaskflowSkillGuidesCreateNativeToolsAndDelete(t *testing.T) { content, err := Files.ReadFile("taskflow/SKILL.md") if err != nil { t.Fatal(err) @@ -14,7 +14,6 @@ func TestTaskflowSkillGuidesCreateOpenAndDelete(t *testing.T) { for _, required := range []string{ "create ", "create --execute", - "open ", "delete ", "ownership.json", "OWNERSHIP_NOT_FOUND", @@ -25,6 +24,22 @@ func TestTaskflowSkillGuidesCreateOpenAndDelete(t *testing.T) { "CREATE_WORKTREE_FAILED", "CONFIG_EDIT_REQUIRED", "直接编辑 taskflow.yaml", + "create --dry-run", + "reuse", + "claude", + "codex", + "--add-dir", + "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1", + "绝对路径", + "execute 完成后", + "PowerShell", + "cmd.exe", + "Set-Location -LiteralPath", + "$env:CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD", + "cd /d", + "set \"CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1\"", + "单引号", + "转义", "--worktree", "dirty", } { @@ -41,6 +56,9 @@ func TestTaskflowSkillGuidesCreateOpenAndDelete(t *testing.T) { "STATE_CONFLICT", "depends_on", "repo add", + "open ", + "TOOL_NOT_FOUND", + "TOOL_EXITED", } { if strings.Contains(text, forbidden) { t.Errorf("skill contains retired guidance %q", forbidden) diff --git a/skills/taskflow/SKILL.md b/skills/taskflow/SKILL.md index 441d724..9df78cc 100644 --- a/skills/taskflow/SKILL.md +++ b/skills/taskflow/SKILL.md @@ -1,17 +1,17 @@ --- name: taskflow -description: 用 Taskflow 安全创建、打开和清理多 Git 仓库 worktree 工作区。用户需要准备隔离工作区、启动 AI CLI 或清理 Taskflow 创建的任务时使用。 +description: 用 Taskflow 安全创建和清理多 Git 仓库 worktree 工作区,并为用户生成原生 AI CLI 命令。用户需要准备隔离工作区、启动 AI CLI 或清理 Taskflow 创建的任务时使用。 --- # Taskflow 工作区向导 -Taskflow 负责三件事:根据声明创建或复用安全的 Git worktree,从准备好的多仓库工作区打开 Codex 或 Claude,以及清理有明确 ownership 记录的任务资源。不要用手写 Git 或文件系统命令替代这些流程。 +Taskflow 负责两件事:根据声明创建或复用安全的 Git worktree,以及清理有明确 ownership 记录的任务资源。对于 AI CLI,先检查工作区,再生成由用户执行的原生 Codex 或 Claude 命令。不要用手写 Git 或文件系统命令替代这些流程。 ## 定位任务 任务目录是 `/`,`--tasks-root` 默认当前目录。若用户没有提供 `task-id`,先询问任务 ID;若没有提供任务根目录,使用当前目录并告知用户。已有任务不要通过扫描目录或猜测名称来选择。 -`taskflow.yaml` 是唯一的持久期望配置;`.taskflow/ownership.json` 只记录由 Taskflow 实际创建的 worktree,不是任务生命周期状态。Taskflow 不创建或读取 state、inventory、validation report 或其他任务生命周期文件。第一个仓库是 open 的 cwd,后续仓库和任务根目录会作为 additional directories。 +`taskflow.yaml` 是唯一的持久期望配置;`.taskflow/ownership.json` 只记录由 Taskflow 实际创建的 worktree,不是任务生命周期状态。Taskflow 不创建或读取 state、inventory、validation report 或其他任务生命周期文件。第一个仓库是生成的 AI CLI 命令的 cwd,后续仓库和任务根目录会作为 additional directories。 ## 新建工作区 @@ -74,17 +74,52 @@ taskflow --json --tasks-root delete --execute delete 要求 ownership manifest 与 taskflow.yaml 完全匹配,并会在任务锁、source-branch 锁和完整 preflight 后删除登记的 worktree、本地任务分支和任务目录。没有 ownership manifest、存在未登记文件、worktree dirty、target/source/branch 不匹配或目标是默认分支时,必须停止并报告,不要改用 shell 删除命令。只有用户明确允许丢弃脏文件和未合并分支时,才使用 `--force --execute`。 -## 打开 CLI +## 生成 AI CLI 命令 -只有所有目标 worktree 的 source common directory、branch 和 path 都匹配时才打开: +只有所有目标 worktree 的 source common directory、branch 和 path 都匹配时才生成命令。新任务要按以下顺序处理: + +1. 先用带 `--repo` 的 `create --dry-run` 预览,向用户说明计划并获得执行批准。 +2. 用户批准后运行带 `--repo` 的 `create --execute` 创建工作区。 +3. execute 完成后,必须再次运行不带 `--repo` 的 dry-run: + + ```bash + taskflow --json --tasks-root create --dry-run + ``` + +只有这次输出中每个 repository 的 action 都是 `reuse` 时才继续。若有 `create` 或冲突,先向用户报告问题,不生成 AI CLI 命令。已有任务也必须先运行这个不带 `--repo` 的 dry-run。匹配但 dirty 的 worktree 仍然可以复用。 + +然后读取 `taskflow.yaml`,使用绝对路径组合命令:第一个 repository 的 worktree 是 cwd,后续 repository worktree 和任务根目录都作为 `--add-dir` 参数。先识别用户要执行命令的 shell;不能判断时先询问,不要假定所有用户都使用 Bash。每个路径都必须按目标 shell 进行引用和转义,不能把原始路径直接插入命令: + +- POSIX shell(sh、bash、zsh)使用单引号;路径中的单引号使用 `'\''` 形式(例如 `'/tmp/a'\''b'`)。Claude 使用 `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 claude ...` 前缀。 +- PowerShell 使用单引号,路径中的单引号写成两个单引号,并使用 `Set-Location -LiteralPath`;Claude 通过 `$env:CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD = '1'` 设置环境变量。 +- cmd.exe 使用双引号包住每个路径,使用 `cd /d "..."` 和 `set "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1"`;对 cmd 元字符进行转义。路径含 `%`、`!` 或无法可靠转义时,改为生成 PowerShell 命令或先询问用户。 + +POSIX shell 示例: ```bash -taskflow --tasks-root open -taskflow --tasks-root open --tool claude -taskflow --tasks-root open --tool codex -- --model +cd '/' +CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 claude \ + --add-dir '' \ + --add-dir '' +``` + +PowerShell 示例: + +```powershell +Set-Location -LiteralPath '\' +$env:CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD = '1' +claude --add-dir '' --add-dir '' +``` + +cmd.exe 示例: + +```bat +cd /d "\" +set "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1" +claude --add-dir "" --add-dir "" ``` -`open` 默认启动 Codex;`--` 后的模型、权限和其他工具参数原样透传。不要透传 `--worktree` 或 `--worktree=...`,避免创建嵌套 worktree。匹配但 dirty 的 worktree 仍然可以打开。 +Codex 使用相同的 cwd、路径引用和 `--add-dir` 参数,只需将工具名替换为 `codex` 并移除 Claude 环境变量。把用户请求的其他工具参数按目标 shell 正确引用后追加在这些 `--add-dir` 参数之后。把完整的、与 shell 匹配的命令展示给用户,由用户在自己的终端执行;不要由 agent shell 代为启动交互式工具。不要加入 `--worktree` 或 `--worktree=...`,避免创建嵌套 worktree;如用户请求这些参数,应省略或拒绝并说明原因。 ## 失败处理 @@ -98,7 +133,7 @@ taskflow --tasks-root open --tool codex -- --model