← Back to task

Commit 3f7bf9c9

commit 3f7bf9c974e83c54cbd7283fea1abe4c55ab4822
Author: Ben Sima <ben@bensima.com>
Date:   Thu Jan 1 11:24:16 2026

    Add comprehensive task management tools (t-295.1-8)
    
    Added new agent tools for full task database access:
    
    - show_task: View task details including description, comments, dependencies
    - update_task_status: Change task status (open, in-progress, review, etc.)
    - create_task: Create new tasks with parent, priority, namespace options
    - add_task_comment: Add comments/progress notes to tasks
    - list_tasks: Query tasks with status/priority/namespace filters
    - task_stats: Get counts by status and priority
    - task_tree: View subtask hierarchy for epics
    
    All tools registered in Telegram.hs agent tool list.
    
    Task-Id: t-295.1
    Task-Id: t-295.2
    Task-Id: t-295.3
    Task-Id: t-295.4
    Task-Id: t-295.5
    Task-Id: t-295.7
    Task-Id: t-295.8

diff --git a/Omni/Agent/Telegram.hs b/Omni/Agent/Telegram.hs
index 5f9ea196..8767a987 100644
--- a/Omni/Agent/Telegram.hs
+++ b/Omni/Agent/Telegram.hs
@@ -1361,7 +1361,16 @@ processEngagedMessage tgConfig provider engineCfg msg uid userName chatId userMe
                       Tasks.ttcChatId = chatId,
                       Tasks.ttcThreadId = Types.tmThreadId msg
                     }
-             in [Tasks.workOnTaskTool taskCtx, Tasks.listReadyTasksTool]
+             in [ Tasks.workOnTaskTool taskCtx,
+                  Tasks.listReadyTasksTool,
+                  Tasks.showTaskTool,
+                  Tasks.updateTaskStatusTool,
+                  Tasks.createTaskTool,
+                  Tasks.addTaskCommentTool,
+                  Tasks.listTasksTool,
+                  Tasks.taskStatsTool,
+                  Tasks.taskTreeTool
+                ]
           else []
       tools = memoryTools <> searchTools <> webReaderTools <> pdfTools <> notesTools <> calendarTools <> todoTools <> messageTools <> hledgerTools <> emailTools <> pythonTools <> httpTools <> outreachTools <> feedbackTools <> fileTools <> skillsTools <> subagentToolList <> auditLogTools <> taskTools
 
diff --git a/Omni/Agent/Tools/Tasks.hs b/Omni/Agent/Tools/Tasks.hs
index ac86cfc9..591167c6 100644
--- a/Omni/Agent/Tools/Tasks.hs
+++ b/Omni/Agent/Tools/Tasks.hs
@@ -2,19 +2,26 @@
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
--- | Task tools for the coding orchestrator.
+-- | Task tools for the agent.
 --
--- Allows Ava to work on tasks from the task database by spawning
--- the pi-orchestrate coder/reviewer loop with Telegram status updates.
+-- Allows the agent to work on and manage tasks from the task database.
 --
 -- : out omni-agent-tools-tasks
 -- : dep aeson
 -- : dep async
 -- : dep process
+-- : dep time
 module Omni.Agent.Tools.Tasks
-  ( -- * Tools
+  ( -- * Task Management Tools
     workOnTaskTool,
     listReadyTasksTool,
+    showTaskTool,
+    updateTaskStatusTool,
+    createTaskTool,
+    addTaskCommentTool,
+    listTasksTool,
+    taskStatsTool,
+    taskTreeTool,
 
     -- * Context for tools
     TaskToolContext (..),
@@ -26,9 +33,11 @@ module Omni.Agent.Tools.Tasks
 where
 
 import Alpha
-import Data.Aeson ((.:), (.=))
+import Data.Aeson ((.:), (.:?), (.=))
 import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.KeyMap as KeyMap
 import qualified Data.Text as Text
+-- import Data.Time (getCurrentTime)
 import qualified Omni.Agent.Engine as Engine
 import qualified Omni.Agent.Telegram.Orchestrator as Orchestrator
 import qualified Omni.Agent.Telegram.Types as TgTypes
@@ -151,18 +160,518 @@ listReadyTasksTool =
 executeListReadyTasks :: Aeson.Value -> IO Aeson.Value
 executeListReadyTasks _ = do
   readyTasks <- Task.getReadyTasks
-  let formatted = map formatTask readyTasks
+  let formatted = map formatTaskBrief readyTasks
   pure
     <| Aeson.object
       [ "success" .= True,
         "count" .= length readyTasks,
         "tasks" .= formatted
       ]
+
+-- | Tool to show task details (t-295.1)
+showTaskTool :: Engine.Tool
+showTaskTool =
+  Engine.Tool
+    { Engine.toolName = "show_task",
+      Engine.toolDescription =
+        "Get detailed information about a task including description, status, "
+          <> "dependencies, comments, and metadata.",
+      Engine.toolJsonSchema =
+        Aeson.object
+          [ "type" .= ("object" :: Text),
+            "properties"
+              .= Aeson.object
+                [ "task_id"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Task ID, e.g. 't-123' or 't-280.2.1'" :: Text)
+                      ]
+                ],
+            "required" .= (["task_id"] :: [Text])
+          ],
+      Engine.toolExecute = executeShowTask
+    }
+
+executeShowTask :: Aeson.Value -> IO Aeson.Value
+executeShowTask v = do
+  case Aeson.fromJSON v of
+    Aeson.Error e ->
+      pure <| Aeson.object ["error" .= Text.pack e]
+    Aeson.Success (args :: WorkOnTaskArgs) -> do
+      let tid = taskId args
+      allTasks <- Task.loadTasks
+      case Task.findTask tid allTasks of
+        Nothing ->
+          pure <| Aeson.object ["error" .= ("Task not found: " <> tid)]
+        Just task ->
+          pure <| formatTaskFull task
+
+-- | Tool to update task status (t-295.2)
+updateTaskStatusTool :: Engine.Tool
+updateTaskStatusTool =
+  Engine.Tool
+    { Engine.toolName = "update_task_status",
+      Engine.toolDescription =
+        "Change the status of a task. Valid statuses: open, in-progress, review, "
+          <> "needs-help, approved, done, draft.",
+      Engine.toolJsonSchema =
+        Aeson.object
+          [ "type" .= ("object" :: Text),
+            "properties"
+              .= Aeson.object
+                [ "task_id"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Task ID to update" :: Text)
+                      ],
+                  "status"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "enum" .= (["open", "in-progress", "review", "needs-help", "approved", "done", "draft"] :: [Text]),
+                        "description" .= ("New status for the task" :: Text)
+                      ]
+                ],
+            "required" .= (["task_id", "status"] :: [Text])
+          ],
+      Engine.toolExecute = executeUpdateTaskStatus
+    }
+
+executeUpdateTaskStatus :: Aeson.Value -> IO Aeson.Value
+executeUpdateTaskStatus v = do
+  case v of
+    Aeson.Object obj -> do
+      let tidM = case KeyMap.lookup "task_id" obj of
+            Just (Aeson.String t) -> Just t
+            _ -> Nothing
+          statusM = case KeyMap.lookup "status" obj of
+            Just (Aeson.String s) -> Just s
+            _ -> Nothing
+      case (tidM, statusM) of
+        (Just tid, Just statusStr) -> do
+          case parseStatus statusStr of
+            Nothing ->
+              pure <| Aeson.object ["error" .= ("Invalid status: " <> statusStr)]
+            Just status -> do
+              result <- try @SomeException <| Task.updateTaskStatus tid status []
+              case result of
+                Left err ->
+                  pure <| Aeson.object ["error" .= tshow err]
+                Right () -> do
+                  -- Re-fetch to get the updated task for response
+                  allTasks <- Task.loadTasks
+                  let mTask = Task.findTask tid allTasks
+                  pure
+                    <| Aeson.object
+                      [ "success" .= True,
+                        "task_id" .= tid,
+                        "new_status" .= tshow status,
+                        "title" .= maybe "" Task.taskTitle mTask
+                      ]
+        _ ->
+          pure <| Aeson.object ["error" .= ("Missing task_id or status" :: Text)]
+    _ ->
+      pure <| Aeson.object ["error" .= ("Invalid arguments" :: Text)]
+
+parseStatus :: Text -> Maybe Task.Status
+parseStatus "open" = Just Task.Open
+parseStatus "in-progress" = Just Task.InProgress
+parseStatus "review" = Just Task.Review
+parseStatus "needs-help" = Just Task.NeedsHelp
+parseStatus "approved" = Just Task.Approved
+parseStatus "done" = Just Task.Done
+parseStatus "draft" = Just Task.Draft
+parseStatus _ = Nothing
+
+-- | Tool to create a new task (t-295.3)
+createTaskTool :: Engine.Tool
+createTaskTool =
+  Engine.Tool
+    { Engine.toolName = "create_task",
+      Engine.toolDescription =
+        "Create a new task in the database. Can specify parent for subtasks, "
+          <> "priority, namespace, and description.",
+      Engine.toolJsonSchema =
+        Aeson.object
+          [ "type" .= ("object" :: Text),
+            "properties"
+              .= Aeson.object
+                [ "title"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Task title (required)" :: Text)
+                      ],
+                  "description"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Detailed description of the task" :: Text)
+                      ],
+                  "parent_id"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Parent task ID for subtasks, e.g. 't-280'" :: Text)
+                      ],
+                  "priority"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "enum" .= (["P0", "P1", "P2", "P3"] :: [Text]),
+                        "description" .= ("Task priority (default: P2)" :: Text)
+                      ],
+                  "namespace"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Code namespace, e.g. 'Omni/Agent/Tools.hs'" :: Text)
+                      ],
+                  "discovered_from"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Task ID this was discovered from (creates soft dependency)" :: Text)
+                      ]
+                ],
+            "required" .= (["title"] :: [Text])
+          ],
+      Engine.toolExecute = executeCreateTask
+    }
+
+data CreateTaskArgs = CreateTaskArgs
+  { ctaTitle :: Text,
+    ctaDescription :: Maybe Text,
+    ctaParentId :: Maybe Text,
+    ctaPriority :: Maybe Text,
+    ctaNamespace :: Maybe Text,
+    ctaDiscoveredFrom :: Maybe Text
+  }
+  deriving (Show)
+
+instance Aeson.FromJSON CreateTaskArgs where
+  parseJSON =
+    Aeson.withObject "CreateTaskArgs" <| \v ->
+      (CreateTaskArgs </ (v .: "title"))
+        <*> (v .:? "description")
+        <*> (v .:? "parent_id")
+        <*> (v .:? "priority")
+        <*> (v .:? "namespace")
+        <*> (v .:? "discovered_from")
+
+executeCreateTask :: Aeson.Value -> IO Aeson.Value
+executeCreateTask v = do
+  case Aeson.fromJSON v of
+    Aeson.Error e ->
+      pure <| Aeson.object ["error" .= Text.pack e]
+    Aeson.Success (args :: CreateTaskArgs) -> do
+      let priority = case ctaPriority args of
+            Just "P0" -> Task.P0
+            Just "P1" -> Task.P1
+            Just "P3" -> Task.P3
+            _ -> Task.P2
+          description = fromMaybe "" (ctaDescription args)
+          -- Build dependencies list
+          deps = case ctaDiscoveredFrom args of
+            Just dfid -> [Task.Dependency dfid Task.DiscoveredFrom]
+            Nothing -> []
+      result <- try @SomeException <| Task.createTask
+        (ctaTitle args)
+        Task.WorkTask
+        (ctaParentId args)
+        (ctaNamespace args)
+        priority
+        Nothing  -- complexity
+        deps
+        description
+      case result of
+        Left err ->
+          pure <| Aeson.object ["error" .= tshow err]
+        Right task ->
+          pure
+            <| Aeson.object
+              [ "success" .= True,
+                "task_id" .= Task.taskId task,
+                "title" .= Task.taskTitle task,
+                "message" .= ("Created task " <> Task.taskId task <> ": " <> Task.taskTitle task)
+              ]
+
+-- | Tool to add a comment to a task (t-295.4)
+addTaskCommentTool :: Engine.Tool
+addTaskCommentTool =
+  Engine.Tool
+    { Engine.toolName = "add_task_comment",
+      Engine.toolDescription =
+        "Add a comment to a task. Comments can include progress updates, "
+          <> "blockers, questions, or any relevant notes.",
+      Engine.toolJsonSchema =
+        Aeson.object
+          [ "type" .= ("object" :: Text),
+            "properties"
+              .= Aeson.object
+                [ "task_id"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Task ID to comment on" :: Text)
+                      ],
+                  "comment"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Comment text" :: Text)
+                      ]
+                ],
+            "required" .= (["task_id", "comment"] :: [Text])
+          ],
+      Engine.toolExecute = executeAddTaskComment
+    }
+
+executeAddTaskComment :: Aeson.Value -> IO Aeson.Value
+executeAddTaskComment v = do
+  case v of
+    Aeson.Object obj -> do
+      let tidM = case KeyMap.lookup "task_id" obj of
+            Just (Aeson.String t) -> Just t
+            _ -> Nothing
+          commentM = case KeyMap.lookup "comment" obj of
+            Just (Aeson.String c) -> Just c
+            _ -> Nothing
+      case (tidM, commentM) of
+        (Just tid, Just comment) -> do
+          let author = Task.Agent Task.Engineer
+          result <- try @SomeException <| Task.addComment tid comment author
+          case result of
+            Left err ->
+              pure <| Aeson.object ["error" .= tshow err]
+            Right _ ->
+              pure
+                <| Aeson.object
+                  [ "success" .= True,
+                    "task_id" .= tid,
+                    "message" .= ("Added comment to " <> tid)
+                  ]
+        _ ->
+          pure <| Aeson.object ["error" .= ("Missing task_id or comment" :: Text)]
+    _ ->
+      pure <| Aeson.object ["error" .= ("Invalid arguments" :: Text)]
+
+-- | Tool to list tasks with filters (t-295.5)
+listTasksTool :: Engine.Tool
+listTasksTool =
+  Engine.Tool
+    { Engine.toolName = "list_tasks",
+      Engine.toolDescription =
+        "List tasks with optional filtering by status, priority, or namespace. "
+          <> "Returns up to 50 tasks matching the criteria.",
+      Engine.toolJsonSchema =
+        Aeson.object
+          [ "type" .= ("object" :: Text),
+            "properties"
+              .= Aeson.object
+                [ "status"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "enum" .= (["open", "in-progress", "review", "needs-help", "approved", "done", "draft"] :: [Text]),
+                        "description" .= ("Filter by status" :: Text)
+                      ],
+                  "priority"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "enum" .= (["P0", "P1", "P2", "P3"] :: [Text]),
+                        "description" .= ("Filter by priority" :: Text)
+                      ],
+                  "namespace"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Filter by namespace prefix" :: Text)
+                      ],
+                  "limit"
+                    .= Aeson.object
+                      [ "type" .= ("integer" :: Text),
+                        "description" .= ("Max results (default: 20, max: 50)" :: Text)
+                      ]
+                ],
+            "required" .= ([] :: [Text])
+          ],
+      Engine.toolExecute = executeListTasks
+    }
+
+data ListTasksArgs = ListTasksArgs
+  { ltaStatus :: Maybe Text,
+    ltaPriority :: Maybe Text,
+    ltaNamespace :: Maybe Text,
+    ltaLimit :: Maybe Int
+  }
+  deriving (Show)
+
+instance Aeson.FromJSON ListTasksArgs where
+  parseJSON =
+    Aeson.withObject "ListTasksArgs" <| \v ->
+      (ListTasksArgs </ (v .:? "status"))
+        <*> (v .:? "priority")
+        <*> (v .:? "namespace")
+        <*> (v .:? "limit")
+
+executeListTasks :: Aeson.Value -> IO Aeson.Value
+executeListTasks v = do
+  case Aeson.fromJSON v of
+    Aeson.Error e ->
+      pure <| Aeson.object ["error" .= Text.pack e]
+    Aeson.Success (args :: ListTasksArgs) -> do
+      allTasks <- Task.loadTasks
+      let limit = min 50 (fromMaybe 20 (ltaLimit args))
+          statusFilter = case ltaStatus args +> parseStatus of
+            Just s -> filter (\t -> Task.taskStatus t == s)
+            Nothing -> identity
+          priorityFilter = case ltaPriority args +> parsePriority of
+            Just p -> filter (\t -> Task.taskPriority t == p)
+            Nothing -> identity
+          namespaceFilter = case ltaNamespace args of
+            Just ns -> filter (\t -> maybe False (Text.isPrefixOf ns) (Task.taskNamespace t))
+            Nothing -> identity
+          filtered = take limit <| namespaceFilter <| priorityFilter <| statusFilter allTasks
+          formatted = map formatTaskBrief filtered
+      pure
+        <| Aeson.object
+          [ "success" .= True,
+            "count" .= length formatted,
+            "tasks" .= formatted
+          ]
+
+parsePriority :: Text -> Maybe Task.Priority
+parsePriority "P0" = Just Task.P0
+parsePriority "P1" = Just Task.P1
+parsePriority "P2" = Just Task.P2
+parsePriority "P3" = Just Task.P3
+parsePriority _ = Nothing
+
+-- | Tool to get task statistics (t-295.8)
+taskStatsTool :: Engine.Tool
+taskStatsTool =
+  Engine.Tool
+    { Engine.toolName = "task_stats",
+      Engine.toolDescription =
+        "Get overall task statistics including counts by status and priority.",
+      Engine.toolJsonSchema =
+        Aeson.object
+          [ "type" .= ("object" :: Text),
+            "properties" .= Aeson.object [],
+            "required" .= ([] :: [Text])
+          ],
+      Engine.toolExecute = executeTaskStats
+    }
+
+executeTaskStats :: Aeson.Value -> IO Aeson.Value
+executeTaskStats _ = do
+  allTasks <- Task.loadTasks
+  let byStatus status = length (filter (\t -> Task.taskStatus t == status) allTasks)
+      byPriority priority = length (filter (\t -> Task.taskPriority t == priority) allTasks)
+  pure
+    <| Aeson.object
+      [ "success" .= True,
+        "total" .= length allTasks,
+        "by_status"
+          .= Aeson.object
+            [ "open" .= byStatus Task.Open,
+              "in_progress" .= byStatus Task.InProgress,
+              "review" .= byStatus Task.Review,
+              "needs_help" .= byStatus Task.NeedsHelp,
+              "approved" .= byStatus Task.Approved,
+              "done" .= byStatus Task.Done,
+              "draft" .= byStatus Task.Draft
+            ],
+        "by_priority"
+          .= Aeson.object
+            [ "P0" .= byPriority Task.P0,
+              "P1" .= byPriority Task.P1,
+              "P2" .= byPriority Task.P2,
+              "P3" .= byPriority Task.P3
+            ]
+      ]
+
+-- | Tool to get task tree/hierarchy (t-295.7)
+taskTreeTool :: Engine.Tool
+taskTreeTool =
+  Engine.Tool
+    { Engine.toolName = "task_tree",
+      Engine.toolDescription =
+        "Get the subtask hierarchy for a task (epic). Shows all children and their status.",
+      Engine.toolJsonSchema =
+        Aeson.object
+          [ "type" .= ("object" :: Text),
+            "properties"
+              .= Aeson.object
+                [ "task_id"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Parent task ID to show tree for" :: Text)
+                      ]
+                ],
+            "required" .= (["task_id"] :: [Text])
+          ],
+      Engine.toolExecute = executeTaskTree
+    }
+
+executeTaskTree :: Aeson.Value -> IO Aeson.Value
+executeTaskTree v = do
+  case Aeson.fromJSON v of
+    Aeson.Error e ->
+      pure <| Aeson.object ["error" .= Text.pack e]
+    Aeson.Success (args :: WorkOnTaskArgs) -> do
+      let tid = taskId args
+      allTasks <- Task.loadTasks
+      case Task.findTask tid allTasks of
+        Nothing ->
+          pure <| Aeson.object ["error" .= ("Task not found: " <> tid)]
+        Just parentTask -> do
+          let children = filter (\t -> Task.taskParent t == Just tid) allTasks
+              formatChild t =
+                Aeson.object
+                  [ "id" .= Task.taskId t,
+                    "title" .= Task.taskTitle t,
+                    "status" .= tshow (Task.taskStatus t)
+                  ]
+          pure
+            <| Aeson.object
+              [ "success" .= True,
+                "task_id" .= tid,
+                "title" .= Task.taskTitle parentTask,
+                "status" .= tshow (Task.taskStatus parentTask),
+                "children" .= map formatChild children,
+                "child_count" .= length children
+              ]
+
+-- | Format a task for brief listing
+formatTaskBrief :: Task.Task -> Aeson.Value
+formatTaskBrief t =
+  Aeson.object
+    [ "id" .= Task.taskId t,
+      "title" .= Task.taskTitle t,
+      "status" .= tshow (Task.taskStatus t),
+      "priority" .= tshow (Task.taskPriority t),
+      "namespace" .= Task.taskNamespace t
+    ]
+
+-- | Format a task with full details
+formatTaskFull :: Task.Task -> Aeson.Value
+formatTaskFull t =
+  Aeson.object
+    [ "success" .= True,
+      "id" .= Task.taskId t,
+      "title" .= Task.taskTitle t,
+      "description" .= Task.taskDescription t,
+      "status" .= tshow (Task.taskStatus t),
+      "priority" .= tshow (Task.taskPriority t),
+      "namespace" .= Task.taskNamespace t,
+      "parent" .= Task.taskParent t,
+      "dependencies" .= map formatDep (Task.taskDependencies t),
+      "comments" .= map formatComment (Task.taskComments t),
+      "type" .= tshow (Task.taskType t),
+      "complexity" .= Task.taskComplexity t,
+      "created_at" .= Task.taskCreatedAt t,
+      "updated_at" .= Task.taskUpdatedAt t
+    ]
   where
-    formatTask t =
+    formatDep d =
+      Aeson.object
+        [ "id" .= Task.depId d,
+          "type" .= tshow (Task.depType d)
+        ]
+    formatComment c =
       Aeson.object
-        [ "id" .= Task.taskId t,
-          "title" .= Task.taskTitle t,
-          "priority" .= tshow (Task.taskPriority t),
-          "namespace" .= Task.taskNamespace t
+        [ "author" .= tshow (Task.commentAuthor c),
+          "time" .= Task.commentCreatedAt c,
+          "text" .= Task.commentText c
         ]