Skip to content
Open
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
47 changes: 44 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,12 +181,53 @@ execute-mode create 会:

当前版本支持 create/delete、`skill install` 和当前 taskflow.yaml 配置。旧 `init/start/status/validate/repo add` 命令、旧字段、state/report/inventory 文件不在运行时兼容范围内。已有任务的 `create --repo` 追加调用也不再支持;请直接编辑 taskflow.yaml。没有 ownership.json 的旧任务不能由 `delete` 自动清理。

## 会话驱动的 AI 工作流

在准备好任务 worktree 后,可以为任务增加独立的 `workflow.yaml`,让 Codex 或 Claude 会话通过全局 `taskflow-workflow` Skill 和宿主 `/loop` 按阶段持续推进。`taskflow.yaml` 仍然只描述 Git worktree;`workflow.yaml` 描述阶段目标、检查命令、重试次数、预算和审批策略。

示例配置见 [`examples/workflow.yaml`](examples/workflow.yaml)。工作流命令使用结构化 JSON 作为 Skill 的控制协议:

```bash
taskflow --json --tasks-root ~/tasks workflow validate DEMO-001
taskflow --json --tasks-root ~/tasks workflow status DEMO-001
```

用户在任务 worktree 中启动 Agent,而不是让 Taskflow 启动嵌套 Agent:

```bash
cd ~/tasks/DEMO-001/worktrees/service
codex
# 或 claude
```

在会话中调用全局 `taskflow-workflow` Skill,然后使用宿主的 `/loop`。每个 tick 只执行一个有界迭代:读取状态、开始或恢复一个 attempt、修改代码、提交结构化 checkpoint,并执行配置中的机器检查。Skill 必须在 `completed`、`paused`、`awaiting_approval`、`needs_attention`、`cancelled` 或 `unknown` 状态停止工作。

用户可以在会话外进行显式控制:

```bash
taskflow --json --tasks-root ~/tasks workflow pause DEMO-001
taskflow --json --tasks-root ~/tasks workflow resume DEMO-001
taskflow --json --tasks-root ~/tasks workflow approve DEMO-001 <approval-id>
taskflow --json --tasks-root ~/tasks workflow cancel DEMO-001
```

运行时状态和证据保存在任务目录的 `.taskflow/` 下,包括 workflow snapshot、JSONL 事件、session lease、attempt 报告和检查结果。工作流只有在所有 required checks 成功后才会进入 `completed`;`needs_approval` 只有在策略允许且 action 被列入 `allowed_actions`(如果配置了)时才会建立审批请求。commit、push、PR、merge、release、deploy、删除和外部写入仍由用户单独授权并执行。

### 会话工作流边界

- `/loop` 是当前 Codex 或 Claude 会话的触发器,不是后台 daemon;关闭会话后不会继续产生新的迭代,但状态可以在下一次会话恢复。
- 每个任务 v1 只允许一个 active attempt,阶段按 `workflow.yaml` 声明顺序串行执行,不支持并行 Agent 或 DAG。
- Skill 指令不能拦截通用 Agent 直接发起的任意 shell 命令;需要强制的命令级审批时,应增加代理、hook、sandbox 或 App Server 层。

没有 `workflow.yaml` 的现有任务继续是普通的 worktree-only Taskflow 任务;workflow 命令不会修改旧的 `taskflow.yaml` 或自动迁移旧运行时文件。

## 非目标

- 需求、规格、角色、契约负责人或项目进度管理
- AI session lease、对话恢复、模型或权限策略
- commit、pull、push、PR、merge、release
- 检查脚本、validation report、状态 daemon 或 Web UI
- 启动、嵌入或监督 Codex/Claude Agent 进程
- 后台 workflow daemon、App Server、Web UI 或远程执行
- 任意 DAG、并行阶段或多 Agent 协调
- 自动 commit、pull、push、PR、merge、release、deploy 或外部写入
- archive、需求归档或发布流程

## 开发和验证
Expand Down
131 changes: 127 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,17 @@ func NewRootCommand() *cobra.Command {
var tasksRoot = "."
var asJSON bool
svc := app.New()
root := &cobra.Command{Use: "taskflow", Short: "Create and manage Git worktrees for AI coding", SilenceUsage: true}
root := &cobra.Command{Use: "taskflow", Short: "Create and manage Git worktrees for AI coding", SilenceUsage: true, SilenceErrors: true}
root.PersistentFlags().StringVar(&tasksRoot, "tasks-root", ".", "task workspace root (default: current directory)")
root.PersistentFlags().BoolVar(&asJSON, "json", false, "emit JSON")
root.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
result := report.New(c.CommandPath(), "")
result.Fail(report.Diagnostic{Code: "INVALID_ARGUMENT", Message: err.Error()})
if renderErr := report.Render(c.OutOrStdout(), result, asJSON); renderErr != nil {
return renderErr
}
return &exitError{code: int(report.ExitConfig)}
})
render := func(c *cobra.Command, r report.Result, code report.ExitCode) error {
if err := report.Render(c.OutOrStdout(), r, asJSON); err != nil {
return err
Expand All @@ -47,7 +55,7 @@ func NewRootCommand() *cobra.Command {

var repositories []string
var dryRun, execute bool
create := &cobra.Command{Use: "create <task-id>", Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error {
create := &cobra.Command{Use: "create <task-id>", Args: exactArgs(1, &asJSON), RunE: func(c *cobra.Command, args []string) error {
r, code := svc.Create(context.Background(), app.CreateOptions{
TasksRoot: tasksRoot,
TaskID: args[0],
Expand All @@ -63,7 +71,7 @@ func NewRootCommand() *cobra.Command {
root.AddCommand(create)

var deleteDryRun, deleteExecute, deleteForce bool
remove := &cobra.Command{Use: "delete <task-id>", Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error {
remove := &cobra.Command{Use: "delete <task-id>", Args: exactArgs(1, &asJSON), RunE: func(c *cobra.Command, args []string) error {
r, code := svc.Delete(context.Background(), app.DeleteOptions{
TasksRoot: tasksRoot,
TaskID: args[0],
Expand All @@ -81,7 +89,7 @@ func NewRootCommand() *cobra.Command {
var projectSkills, forceSkills bool
var skillTools []string
skillCmd := &cobra.Command{Use: "skill", Short: "Install Taskflow skills for AI coding agents"}
installSkills := &cobra.Command{Use: "install", Args: cobra.NoArgs, RunE: func(c *cobra.Command, args []string) error {
installSkills := &cobra.Command{Use: "install", Args: exactArgs(0, &asJSON), RunE: func(c *cobra.Command, args []string) error {
targets, err := skillTargets(skillTools, projectSkills)
r := report.New("skill install", "")
if err != nil {
Expand All @@ -101,9 +109,124 @@ func NewRootCommand() *cobra.Command {
installSkills.Flags().StringArrayVar(&skillTools, "tool", nil, "install for codex or claude (repeatable; defaults to both)")
skillCmd.AddCommand(installSkills)
root.AddCommand(skillCmd)
root.AddCommand(newWorkflowCommand(svc, &tasksRoot, &asJSON, render))
return root
}

type workflowRenderer func(*cobra.Command, report.Result, report.ExitCode) error

func newWorkflowCommand(svc app.Service, tasksRoot *string, asJSON *bool, render workflowRenderer) *cobra.Command {
workflowCmd := &cobra.Command{Use: "workflow", Short: "Run and inspect a session-driven workflow"}

validate := &cobra.Command{Use: "validate <task-id>", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error {
r, code := svc.WorkflowValidate(context.Background(), app.WorkflowOptions{TasksRoot: *tasksRoot, TaskID: args[0]})
return render(c, r, code)
}}
workflowCmd.AddCommand(validate)

status := &cobra.Command{Use: "status <task-id>", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error {
r, code := svc.WorkflowStatus(context.Background(), app.WorkflowOptions{TasksRoot: *tasksRoot, TaskID: args[0]})
return render(c, r, code)
}}
workflowCmd.AddCommand(status)

var begin app.WorkflowOptions
beginCmd := &cobra.Command{Use: "begin <task-id>", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error {
begin.TasksRoot, begin.TaskID = *tasksRoot, args[0]
r, code := svc.WorkflowBegin(context.Background(), begin)
return render(c, r, code)
}}
beginCmd.Flags().StringVar(&begin.Engine, "engine", "unknown", "active host engine: codex or claude")
beginCmd.Flags().StringVar(&begin.SessionID, "session", "", "active Agent session reference")
beginCmd.Flags().StringVar(&begin.OperationID, "operation-id", "", "stable operation identifier for retries")
beginCmd.Flags().DurationVar(&begin.LeaseTTL, "lease-ttl", 0, "workflow lease duration (default 15m)")
workflowCmd.AddCommand(beginCmd)

var checkpoint app.WorkflowOptions
checkpointCmd := &cobra.Command{Use: "checkpoint <task-id>", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error {
checkpoint.TasksRoot, checkpoint.TaskID = *tasksRoot, args[0]
r, code := svc.WorkflowCheckpoint(context.Background(), checkpoint)
return render(c, r, code)
}}
checkpointCmd.Flags().StringVar(&checkpoint.AttemptID, "attempt-id", "", "active attempt identifier")
checkpointCmd.Flags().StringVar(&checkpoint.OwnerToken, "owner-token", "", "active workflow lease owner token")
checkpointCmd.Flags().StringVar(&checkpoint.ReportPath, "report-file", "", "JSON checkpoint report path under the task root")
checkpointCmd.Flags().StringVar(&checkpoint.OperationID, "operation-id", "", "stable operation identifier for retries")
checkpointCmd.Flags().DurationVar(&checkpoint.LeaseTTL, "lease-ttl", 0, "workflow lease duration (default 15m)")
workflowCmd.AddCommand(checkpointCmd)

var verify app.WorkflowOptions
verifyCmd := &cobra.Command{Use: "verify <task-id>", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error {
verify.TasksRoot, verify.TaskID = *tasksRoot, args[0]
r, code := svc.WorkflowVerify(context.Background(), verify)
return render(c, r, code)
}}
verifyCmd.Flags().StringVar(&verify.AttemptID, "attempt-id", "", "active attempt identifier")
verifyCmd.Flags().StringVar(&verify.OwnerToken, "owner-token", "", "active workflow lease owner token")
verifyCmd.Flags().StringVar(&verify.OperationID, "operation-id", "", "stable operation identifier for retries")
workflowCmd.AddCommand(verifyCmd)

var pause app.WorkflowOptions
pauseCmd := &cobra.Command{Use: "pause <task-id>", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error {
pause.TasksRoot, pause.TaskID = *tasksRoot, args[0]
r, code := svc.WorkflowPause(context.Background(), pause)
return render(c, r, code)
}}
pauseCmd.Flags().StringVar(&pause.OwnerToken, "owner-token", "", "active workflow lease owner token")
pauseCmd.Flags().StringVar(&pause.OperationID, "operation-id", "", "stable operation identifier for retries")
pauseCmd.Flags().StringVar(&pause.Reason, "reason", "", "reason for pausing")
workflowCmd.AddCommand(pauseCmd)

var resume app.WorkflowOptions
resumeCmd := &cobra.Command{Use: "resume <task-id>", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error {
resume.TasksRoot, resume.TaskID = *tasksRoot, args[0]
r, code := svc.WorkflowResume(context.Background(), resume)
return render(c, r, code)
}}
resumeCmd.Flags().BoolVar(&resume.Recover, "recover", false, "explicitly recover unknown or attention state")
resumeCmd.Flags().StringVar(&resume.OperationID, "operation-id", "", "stable operation identifier for retries")
workflowCmd.AddCommand(resumeCmd)

var approve app.WorkflowOptions
approveCmd := &cobra.Command{Use: "approve <task-id> <approval-id>", Args: exactArgs(2, asJSON), RunE: func(c *cobra.Command, args []string) error {
approve.TasksRoot, approve.TaskID, approve.ApprovalID = *tasksRoot, args[0], args[1]
r, code := svc.WorkflowApprove(context.Background(), approve)
return render(c, r, code)
}}
approveCmd.Flags().StringVar(&approve.Decision, "decision", "approve", "approval decision: approve or reject")
approveCmd.Flags().StringVar(&approve.Reason, "reason", "", "reason for the approval decision")
approveCmd.Flags().StringVar(&approve.OperationID, "operation-id", "", "stable operation identifier for retries")
workflowCmd.AddCommand(approveCmd)

var cancel app.WorkflowOptions
cancelCmd := &cobra.Command{Use: "cancel <task-id>", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error {
cancel.TasksRoot, cancel.TaskID = *tasksRoot, args[0]
r, code := svc.WorkflowCancel(context.Background(), cancel)
return render(c, r, code)
}}
cancelCmd.Flags().StringVar(&cancel.OwnerToken, "owner-token", "", "active workflow lease owner token")
cancelCmd.Flags().StringVar(&cancel.OperationID, "operation-id", "", "stable operation identifier for retries")
cancelCmd.Flags().StringVar(&cancel.Reason, "reason", "", "reason for cancelling")
workflowCmd.AddCommand(cancelCmd)

return workflowCmd
}

func exactArgs(expected int, asJSON *bool) cobra.PositionalArgs {
return func(c *cobra.Command, args []string) error {
if len(args) == expected {
return nil
}
message := fmt.Sprintf("expected %d argument(s), got %d", expected, len(args))
result := report.New(c.CommandPath(), "")
result.Fail(report.Diagnostic{Code: "INVALID_ARGUMENT", Message: message})
if err := report.Render(c.OutOrStdout(), result, *asJSON); err != nil {
return err
}
return &exitError{code: int(report.ExitConfig)}
}
}

func loadDiagnostic(err error) report.Diagnostic {
return report.Diagnostic{Code: "INVALID_CONFIGURATION", Message: err.Error()}
}
Expand Down
38 changes: 38 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package cmd

import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/chenquan/taskflow/internal/report"
)

func TestVersionUsesCobraCommand(t *testing.T) {
Expand Down Expand Up @@ -162,6 +165,41 @@ func TestSkillScope(t *testing.T) {
}
}

func TestJSONArgumentFailuresRemainMachineReadable(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "missing positional argument", args: []string{"--json", "workflow", "status"}},
{name: "unknown flag", args: []string{"--json", "workflow", "status", "TASK-1", "--not-a-flag"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var output bytes.Buffer
root := NewRootCommand()
root.SetOut(&output)
root.SetErr(&output)
root.SetArgs(tt.args)
if err := root.Execute(); err == nil {
t.Fatal("expected argument failure")
}
var envelope struct {
OK bool `json:"ok"`
Errors []report.Diagnostic `json:"errors"`
}
if err := json.Unmarshal(output.Bytes(), &envelope); err != nil {
t.Fatalf("argument failure is not JSON: %v: %s", err, output.String())
}
if envelope.OK || len(envelope.Errors) != 1 || envelope.Errors[0].Code != "INVALID_ARGUMENT" {
t.Fatalf("unexpected argument envelope: %#v", envelope)
}
if strings.Contains(output.String(), "Error:") {
t.Fatalf("Cobra text leaked into JSON output: %s", output.String())
}
})
}
}

func TestPublicCommandsAreLimitedToCreateDeleteVersionAndSkill(t *testing.T) {
root := NewRootCommand()
seen := map[string]bool{}
Expand Down
Loading
Loading