Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -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、合并、发布或验证脚本。这些操作继续由用户和各仓库自己的流程负责。

Expand All @@ -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 信息

## 安装

Expand Down Expand Up @@ -58,7 +58,7 @@ taskflow skill install --project --tool claude

## 快速开始

`--tasks-root` 默认是当前目录。仓库声明顺序必须稳定:第一个仓库是 `open` 的工作目录,后续仓库作为 additional directories。
`--tasks-root` 默认是当前目录。仓库声明顺序必须稳定:第一个仓库是生成的 AI CLI 命令的工作目录,后续仓库作为 additional directories。

先预览,确认后执行:

Expand All @@ -73,17 +73,21 @@ 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
```

`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 <task-id> --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

## 重试和修改配置

Expand Down Expand Up @@ -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` 自动清理。

## 非目标

Expand All @@ -194,7 +198,7 @@ go test -race ./...
go test ./cmd -run 'TestE2E' -count=1
```

`skill install` 属于发布集成命令,不参与任务工作区的 create/open/delete 生命周期。
`skill install` 属于发布集成命令,不参与任务工作区的 create/delete 生命周期。

## 许可证

Expand Down
33 changes: 21 additions & 12 deletions cmd/e2e_safety_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down
16 changes: 1 addition & 15 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <task-id> [-- <tool-args>...]", 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 <task-id>", Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error {
r, code := svc.Delete(context.Background(), app.DeleteOptions{
Expand Down
6 changes: 3 additions & 3 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
50 changes: 0 additions & 50 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@ import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"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"
Expand Down Expand Up @@ -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
}
83 changes: 7 additions & 76 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
}
Loading
Loading