summaryrefslogtreecommitdiff
path: root/Omni/Task
diff options
context:
space:
mode:
authorBen Sima <ben@bsima.me>2025-11-08 16:13:29 -0500
committerBen Sima <ben@bsima.me>2025-11-08 16:15:15 -0500
commit3bf1691f4e32235f84f5cff9d6e4a3fdb9a57ffc (patch)
treed33fccbf6b54164bd97320cad23c9109b3ef6516 /Omni/Task
parentce6d313edbf5c545d16d88d28be867122b7c3d1b (diff)
Add task manager for AI agents
Implemented a dependency-aware task tracker inspired by beads: - Task CRUD operations (create, list, update, ready) - Dependency tracking and ready work detection - JSONL storage with git sync via hooks - Export/import for cross-machine synchronization - Short base62-encoded task IDs (e.g., t-1ky7gJ2) Added comprehensive AGENTS.md documentation: - Task manager usage and workflows - Development tools (bild, lint, repl.sh) - Git-branchless workflow guidelines - Coding conventions Integrated with git hooks for auto-sync: - post-merge/post-checkout: import tasks - pre-commit/pre-push: export tasks Also includes beads design analysis document for reference. Completed tasks: - t-a1b2c3: Show help text when invoked without args - t-d4e5f6: Move dev instructions to AGENTS.md - t-g7h8i9: Implement shorter task IDs - t-p6q7r8: Add git-branchless workflow docs https: //ampcode.com/threads/T-85f4ee29-a529-4f59-ac6f-6ffec75b6a56 Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-85f4ee29-a529-4f59-ac6f-6ffec75b6a56
Diffstat (limited to 'Omni/Task')
-rw-r--r--Omni/Task/Core.hs223
1 files changed, 223 insertions, 0 deletions
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
new file mode 100644
index 0000000..f49a8d2
--- /dev/null
+++ b/Omni/Task/Core.hs
@@ -0,0 +1,223 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Omni.Task.Core where
+
+import Alpha
+import Data.Aeson (FromJSON, ToJSON, decode, encode)
+import qualified Data.ByteString.Lazy.Char8 as BLC
+import qualified Data.Text as T
+import qualified Data.Text.IO as TIO
+import Data.Time (UTCTime, diffTimeToPicoseconds, getCurrentTime, utctDayTime)
+import GHC.Generics ()
+import System.Directory (createDirectoryIfMissing, doesFileExist)
+
+-- Core data types
+data Task = Task
+ { taskId :: Text,
+ taskTitle :: Text,
+ taskProject :: Text,
+ taskStatus :: Status,
+ taskDependencies :: [Text], -- List of task IDs this depends on
+ taskCreatedAt :: UTCTime,
+ taskUpdatedAt :: UTCTime
+ }
+ deriving (Show, Eq, Generic)
+
+data Status = Open | InProgress | Done
+ deriving (Show, Eq, Generic)
+
+instance ToJSON Status
+
+instance FromJSON Status
+
+instance ToJSON Task
+
+instance FromJSON Task
+
+-- Initialize the task database
+initTaskDb :: IO ()
+initTaskDb = do
+ createDirectoryIfMissing True ".tasks"
+ exists <- doesFileExist ".tasks/tasks.jsonl"
+ unless exists <| do
+ TIO.writeFile ".tasks/tasks.jsonl" ""
+ putText "Initialized task database at .tasks/tasks.jsonl"
+
+-- Generate a short ID using base62 encoding of timestamp
+generateId :: IO Text
+generateId = do
+ now <- getCurrentTime
+ -- Convert current time to microseconds since midnight
+ let dayTime = utctDayTime now
+ microseconds = diffTimeToPicoseconds dayTime `div` 1000000
+ -- Convert to base62 for shorter IDs
+ encoded = toBase62 (fromIntegral microseconds)
+ pure <| "t-" <> T.pack encoded
+
+-- Convert number to base62 (0-9, a-z, A-Z)
+toBase62 :: Integer -> String
+toBase62 0 = "0"
+toBase62 n = reverse <| go n
+ where
+ alphabet = ['0' .. '9'] ++ ['a' .. 'z'] ++ ['A' .. 'Z']
+ go 0 = []
+ go x =
+ let (q, r) = x `divMod` 62
+ idx = fromIntegral r
+ char = case drop idx alphabet of
+ (c : _) -> c
+ [] -> '0' -- Fallback (should never happen)
+ in char : go q
+
+-- Load all tasks from JSONL file
+loadTasks :: IO [Task]
+loadTasks = do
+ exists <- doesFileExist ".tasks/tasks.jsonl"
+ if exists
+ then do
+ content <- TIO.readFile ".tasks/tasks.jsonl"
+ let taskLines = T.lines content
+ pure <| mapMaybe decodeTask taskLines
+ else pure []
+ where
+ decodeTask :: Text -> Maybe Task
+ decodeTask line =
+ if T.null line
+ then Nothing
+ else decode (BLC.pack <| T.unpack line)
+
+-- Save a single task (append to JSONL)
+saveTask :: Task -> IO ()
+saveTask task = do
+ let json = encode task
+ BLC.appendFile ".tasks/tasks.jsonl" (json <> "\n")
+
+-- Create a new task
+createTask :: Text -> Text -> [Text] -> IO Task
+createTask title project deps = do
+ tid <- generateId
+ now <- getCurrentTime
+ let task =
+ Task
+ { taskId = tid,
+ taskTitle = title,
+ taskProject = project,
+ taskStatus = Open,
+ taskDependencies = deps,
+ taskCreatedAt = now,
+ taskUpdatedAt = now
+ }
+ saveTask task
+ pure task
+
+-- Update task status
+updateTaskStatus :: Text -> Status -> IO ()
+updateTaskStatus tid newStatus = do
+ tasks <- loadTasks
+ now <- getCurrentTime
+ let updatedTasks = map updateIfMatch tasks
+ updateIfMatch t =
+ if taskId t == tid
+ then t {taskStatus = newStatus, taskUpdatedAt = now}
+ else t
+ -- Rewrite the entire file (simple approach for MVP)
+ TIO.writeFile ".tasks/tasks.jsonl" ""
+ traverse_ saveTask updatedTasks
+
+-- List tasks, optionally filtered by project
+listTasks :: Maybe Text -> IO [Task]
+listTasks maybeProject = do
+ tasks <- loadTasks
+ pure <| case maybeProject of
+ Nothing -> tasks
+ Just proj -> filter (\t -> taskProject t == proj) tasks
+
+-- Get ready tasks (not blocked by dependencies)
+getReadyTasks :: IO [Task]
+getReadyTasks = do
+ allTasks <- loadTasks
+ let openTasks = filter (\t -> taskStatus t /= Done) allTasks
+ doneIds = map taskId <| filter (\t -> taskStatus t == Done) allTasks
+ isReady task = all (`elem` doneIds) (taskDependencies task)
+ pure <| filter isReady openTasks
+
+-- Show dependency tree for a task
+showDependencyTree :: Text -> IO ()
+showDependencyTree tid = do
+ tasks <- loadTasks
+ case filter (\t -> taskId t == tid) tasks of
+ [] -> putText "Task not found"
+ (task : _) -> printTree tasks task 0
+ where
+ printTree :: [Task] -> Task -> Int -> IO ()
+ printTree allTasks task indent = do
+ putText <| T.pack (replicate (indent * 2) ' ') <> taskId task <> ": " <> taskTitle task
+ let deps = filter (\t -> taskId t `elem` taskDependencies task) allTasks
+ traverse_ (\dep -> printTree allTasks dep (indent + 1)) deps
+
+-- Helper to print a task
+printTask :: Task -> IO ()
+printTask t =
+ putText
+ <| taskId t
+ <> " ["
+ <> T.pack (show (taskStatus t))
+ <> "] "
+ <> taskTitle t
+ <> " ("
+ <> taskProject t
+ <> ")"
+
+-- Export tasks: Consolidate JSONL file (remove duplicates, keep latest version)
+exportTasks :: IO ()
+exportTasks = do
+ tasks <- loadTasks
+ -- Rewrite the entire file with deduplicated tasks
+ TIO.writeFile ".tasks/tasks.jsonl" ""
+ traverse_ saveTask tasks
+
+-- Import tasks: Read from another JSONL file and merge with existing tasks
+importTasks :: FilePath -> IO ()
+importTasks filePath = do
+ exists <- doesFileExist filePath
+ unless exists <| panic (T.pack filePath <> " does not exist")
+
+ -- Load tasks from import file
+ content <- TIO.readFile filePath
+ let importLines = T.lines content
+ importedTasks = mapMaybe decodeTask importLines
+
+ -- Load existing tasks
+ existingTasks <- loadTasks
+
+ -- Create a map of existing task IDs for quick lookup
+ let existingIds = map taskId existingTasks
+ -- Filter to only new tasks (not already in our database)
+ newTasks = filter (\t -> taskId t `notElem` existingIds) importedTasks
+ -- For tasks that exist, update them with imported data
+ updatedTasks = map (updateWithImported importedTasks) existingTasks
+ -- Combine: updated existing tasks + new tasks
+ allTasks = updatedTasks ++ newTasks
+
+ -- Rewrite tasks.jsonl with merged data
+ TIO.writeFile ".tasks/tasks.jsonl" ""
+ traverse_ saveTask allTasks
+ where
+ decodeTask :: Text -> Maybe Task
+ decodeTask line =
+ if T.null line
+ then Nothing
+ else decode (BLC.pack <| T.unpack line)
+
+ -- Update an existing task if there's a newer version in imported tasks
+ updateWithImported :: [Task] -> Task -> Task
+ updateWithImported imported existing =
+ case filter (\t -> taskId t == taskId existing) imported of
+ [] -> existing -- No imported version, keep existing
+ (importedTask : _) ->
+ -- Use imported version if it's newer (based on updatedAt)
+ if taskUpdatedAt importedTask > taskUpdatedAt existing
+ then importedTask
+ else existing