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
38 changes: 29 additions & 9 deletions internal/ai/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,21 @@ const routerSystemPrompt = `You are a task router. Given a task description, rep
without asking clarifying questions? A task needs a clear action and enough detail
to produce a concrete output.

2. If actionable, where should it run?
2. If actionable, where should it run? A task can span multiple repositories when the
change genuinely needs to land in several codebases simultaneously (e.g. a shared
library update that must be reflected in both a backend and a frontend repo, or an
API change that requires coordinated updates in an SDK and a service). Only assign
multiple repos when the work truly requires parallel changes across them — do not
split a task that belongs to a single repo.

Respond with a JSON object (no markdown fences, no explanation):

If it maps to a configured repo (code, infra, data pipelines in that codebase):
If it maps to a single configured repo (code, infra, data pipelines in that codebase):
{"repo": "repo-name", "actionable": true}

If it genuinely spans multiple configured repos (parallel changes required in each):
{"repos": ["repo-a", "repo-b"], "actionable": true}

If the primary deliverable is in an external system via a configured MCP
(Notion page, Linear issue, etc.):
{"mcp": "mcp-name", "actionable": true}
Expand All @@ -33,22 +41,26 @@ If NOT actionable (too vague, missing context):
{"actionable": false, "missing": "brief description of what's missing"}

Routing priority: prefer repo when the task is mainly code/config in that repo.
Prefer MCP when the task is mainly creating/updating records in that external tool.
Use generic only when neither applies. Never invent repo or MCP names.
Use repos (plural) only when the task explicitly requires coordinated changes across
multiple repos. Prefer MCP when the task is mainly creating/updating records in that
external tool. Use generic only when neither applies. Never invent repo or MCP names.

Examples:
- "create Airflow DAG for hourly dbt run" with repo "data-pipelines" → {"repo": "data-pipelines", "actionable": true}
- "bump shared-lib to v2 in both backend and frontend" with repos "backend", "frontend" → {"repos": ["backend", "frontend"], "actionable": true}
- "add auth middleware and update the SDK to use the new token format" with repos "api", "sdk" → {"repos": ["api", "sdk"], "actionable": true}
- "create a Notion page for Q2 planning" with mcp "notion" → {"mcp": "notion", "actionable": true}
- "file Linear issue for broken DAG alert" with mcp "linear" → {"mcp": "linear", "actionable": true}
- "write a SQL procedure to migrate historical data" with no matching repo → {"generic": true, "actionable": true}
- "check if we got updates" → {"actionable": false, "missing": "which system, what kind of updates, what to do with them"}`

type RouteResult struct {
Repo string `json:"repo"`
MCP string `json:"mcp"`
Generic bool `json:"generic"`
Actionable bool `json:"actionable"`
Missing string `json:"missing,omitempty"`
Repo string `json:"repo"`
Repos []string `json:"repos,omitempty"`
MCP string `json:"mcp"`
Generic bool `json:"generic"`
Actionable bool `json:"actionable"`
Missing string `json:"missing,omitempty"`
}

func (c *Client) Route(taskText string, repos []config.Repo, mcps []config.MCP) (*RouteResult, error) {
Expand Down Expand Up @@ -95,8 +107,16 @@ func (c *Client) Route(taskText string, repos []config.Repo, mcps []config.MCP)
// MCP takes precedence over accidental repo field with same name.
if route.MCP != "" {
route.Repo = ""
route.Repos = nil
route.Generic = false
}

// Unify single-repo "repo" field into the "repos" slice so callers always
// iterate Repos and never need to check both fields.
if route.Repo != "" && len(route.Repos) == 0 {
route.Repos = []string{route.Repo}
route.Repo = ""
}

return &route, nil
}
16 changes: 16 additions & 0 deletions internal/taskfile/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ func (t Task) FullText() string {
return t.Text + "\n" + t.Description
}

// Targets returns the list of assigned target names.
// A task can have multiple targets separated by commas (e.g. "repo-a,repo-b").
func (t Task) Targets() []string {
if t.Target == "" {
return nil
}
parts := strings.Split(t.Target, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return out
}

var taskRe = regexp.MustCompile(`^-\s*\[([ xX])\]\s*(.*)$`)

func Parse(path string) ([]Task, error) {
Expand Down
62 changes: 62 additions & 0 deletions internal/taskfile/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,68 @@ func TestTask_FullText(t *testing.T) {
}
}

func TestTask_Targets(t *testing.T) {
tests := []struct {
name string
target string
want []string
}{
{"empty", "", nil},
{"single", "my-repo", []string{"my-repo"}},
{"multi", "repo-a,repo-b", []string{"repo-a", "repo-b"}},
{"multi three", "repo-a,repo-b,generic", []string{"repo-a", "repo-b", "generic"}},
{"trims spaces", "repo-a, repo-b", []string{"repo-a", "repo-b"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
task := Task{Target: tt.target}
got := task.Targets()
if len(got) != len(tt.want) {
t.Fatalf("got %v want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("[%d] got %q want %q", i, got[i], tt.want[i])
}
}
})
}
}

func TestParse_MultiTarget(t *testing.T) {
path := writeTemp(t, "- [ ] @agent:repo-a,repo-b do the thing\n")
tasks, err := Parse(path)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 {
t.Fatalf("expected 1 task, got %d", len(tasks))
}
task := tasks[0]
if task.Tag != TagAgent {
t.Errorf("tag: got %q want %q", task.Tag, TagAgent)
}
if task.Target != "repo-a,repo-b" {
t.Errorf("target: got %q want %q", task.Target, "repo-a,repo-b")
}
if task.Text != "do the thing" {
t.Errorf("text: got %q want %q", task.Text, "do the thing")
}
targets := task.Targets()
if len(targets) != 2 || targets[0] != "repo-a" || targets[1] != "repo-b" {
t.Errorf("targets: got %v", targets)
}
}

func TestFormatTaskLine_MultiTarget(t *testing.T) {
task := Task{Status: Open, Tag: TagAgent, Target: "repo-a,repo-b", Text: "do the thing"}
got := FormatTaskLine(task)
want := "- [ ] @agent:repo-a,repo-b do the thing"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}

func TestUpdateTask(t *testing.T) {
path := writeTemp(t, `## Tasks
- [ ] Old title
Expand Down
7 changes: 7 additions & 0 deletions internal/tracker/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sort"
"sync"
"syscall"
"time"
)

type Store struct {
Expand Down Expand Up @@ -45,6 +46,12 @@ func TaskID(repoName, taskText string) string {
return fmt.Sprintf("%x", h[:8])
}

// DispatchID returns a unique id for each launch (tracker entry, log file, branch suffix).
func DispatchID(repoName, taskText string) string {
h := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", repoName, taskText, time.Now().UnixNano())))
return fmt.Sprintf("%x", h[:8])
}

func (s *Store) load() *state {
st := &state{Tasks: make(map[string]*Task)}
data, err := os.ReadFile(s.statePath())
Expand Down
14 changes: 9 additions & 5 deletions internal/tracker/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,18 @@ func (t Task) WorktreePath() string {

var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`)

// BranchName generates a branch name like "td/create-snowflake-task".
func BranchName(taskText string) string {
const branchIDSuffixLen = 9 // "-" + 8-char dispatch id

// BranchName generates a branch name like "td/create-snowflake-task-a1b2c3d4".
// dispatchID must be unique per launch so concurrent dispatches never share a branch.
func BranchName(taskText, dispatchID string) string {
s := strings.ToLower(taskText)
s = nonAlnum.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if len(s) > 60 {
s = s[:60]
maxSlug := 60 - branchIDSuffixLen
if len(s) > maxSlug {
s = s[:maxSlug]
s = strings.TrimRight(s, "-")
}
return "td/" + s
return "td/" + s + "-" + dispatchID
}
34 changes: 28 additions & 6 deletions internal/tracker/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,31 @@ import (
)

func TestBranchName(t *testing.T) {
const id = "a1b2c3d4"
tests := []struct {
name string
in string
want string
maxLen int // 0 = exact match only
}{
{"simple phrase", "Create Snowflake task", "td/create-snowflake-task", 0},
{"normalizes whitespace and case", " Mixed CASE ", "td/mixed-case", 0},
{"empty input", "", "td/", 0},
{"simple phrase", "Create Snowflake task", "td/create-snowflake-task-a1b2c3d4", 0},
{"normalizes whitespace and case", " Mixed CASE ", "td/mixed-case-a1b2c3d4", 0},
{"empty input", "", "td/-a1b2c3d4", 0},
{
name: "truncates long slug",
in: strings.Repeat("word-", 20) + "end",
maxLen: len("td/") + 60,
},
{
name: "different dispatch ids produce different branches",
in: "Same task text",
want: "td/same-task-text-a1b2c3d4",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := BranchName(tt.in)
got := BranchName(tt.in, id)
if tt.maxLen > 0 {
if len(got) > tt.maxLen {
t.Errorf("len %d exceeds max %d: %q", len(got), tt.maxLen, got)
Expand All @@ -39,6 +45,14 @@ func TestBranchName(t *testing.T) {
}
}

func TestBranchName_uniquePerDispatch(t *testing.T) {
a := BranchName("Fix login bug", "11111111")
b := BranchName("Fix login bug", "22222222")
if a == b {
t.Errorf("expected different branches, both %q", a)
}
}

func TestTask_WorktreePath(t *testing.T) {
tests := []struct {
name string
Expand All @@ -47,8 +61,8 @@ func TestTask_WorktreePath(t *testing.T) {
}{
{
name: "slashes become plus",
task: Task{RepoPath: "/repos/foo", Branch: "td/my-feature"},
wantPath: "/repos/foo/.claude/worktrees/td+my-feature",
task: Task{RepoPath: "/repos/foo", Branch: "td/my-feature-a1b2c3d4"},
wantPath: "/repos/foo/.claude/worktrees/td+my-feature-a1b2c3d4",
},
}

Expand Down Expand Up @@ -126,3 +140,11 @@ func TestTaskID(t *testing.T) {
})
}
}

func TestDispatchID_uniquePerCall(t *testing.T) {
a := DispatchID("repo", "task")
b := DispatchID("repo", "task")
if a == b {
t.Errorf("expected different dispatch ids, both %q", a)
}
}
Loading
Loading