diff options
Diffstat (limited to 'Omni')
| -rwxr-xr-x | Omni/Ide/hooks/post-checkout | 6 | ||||
| -rwxr-xr-x | Omni/Ide/hooks/post-merge | 6 | ||||
| -rwxr-xr-x | Omni/Ide/hooks/pre-commit | 6 | ||||
| -rwxr-xr-x | Omni/Ide/hooks/pre-push | 6 | ||||
| -rw-r--r-- | Omni/Task.hs | 148 | ||||
| -rw-r--r-- | Omni/Task/Core.hs | 223 |
6 files changed, 395 insertions, 0 deletions
diff --git a/Omni/Ide/hooks/post-checkout b/Omni/Ide/hooks/post-checkout index 85541a2..3fe14b5 100755 --- a/Omni/Ide/hooks/post-checkout +++ b/Omni/Ide/hooks/post-checkout @@ -14,6 +14,12 @@ elif [[ ${#changed[@]} -gt 0 ]] then MakeTags "${changed[@]}" fi + +# Task manager: Import tasks after branch switch +if [ -f .tasks/tasks.jsonl ]; then + task import -i .tasks/tasks.jsonl 2>/dev/null || true +fi + ## START BRANCHLESS CONFIG git branchless hook post-checkout "$@" diff --git a/Omni/Ide/hooks/post-merge b/Omni/Ide/hooks/post-merge index fcfd314..3e0495b 100755 --- a/Omni/Ide/hooks/post-merge +++ b/Omni/Ide/hooks/post-merge @@ -1,5 +1,11 @@ #!/usr/bin/env bash "${CODEROOT:?}"/Omni/Ide/hooks/post-checkout 'HEAD@{1}' HEAD + +# Task manager: Import tasks after git pull/merge +if [ -f .tasks/tasks.jsonl ]; then + task import -i .tasks/tasks.jsonl 2>/dev/null || true +fi + ## START BRANCHLESS CONFIG git branchless hook post-merge "$@" diff --git a/Omni/Ide/hooks/pre-commit b/Omni/Ide/hooks/pre-commit index 06f1716..d096f5b 100755 --- a/Omni/Ide/hooks/pre-commit +++ b/Omni/Ide/hooks/pre-commit @@ -18,4 +18,10 @@ fi done lint "${changed[@]}" + + # Task manager: Export tasks before commit + if [ -d .tasks ]; then + task export --flush 2>/dev/null || true + git add .tasks/tasks.jsonl 2>/dev/null || true + fi ## diff --git a/Omni/Ide/hooks/pre-push b/Omni/Ide/hooks/pre-push index 00110bd..adbf858 100755 --- a/Omni/Ide/hooks/pre-push +++ b/Omni/Ide/hooks/pre-push @@ -1,5 +1,11 @@ #!/usr/bin/env bash set -euo pipefail + +# Task manager: Ensure tasks are exported before push +if [ -d .tasks ]; then + task export --flush 2>/dev/null || true +fi + remote="$1" z40=0000000000000000000000000000000000000000 IFS=" " diff --git a/Omni/Task.hs b/Omni/Task.hs new file mode 100644 index 0000000..be54d3d --- /dev/null +++ b/Omni/Task.hs @@ -0,0 +1,148 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE NoImplicitPrelude #-} + +-- : out task +module Omni.Task where + +import Alpha +import qualified Data.Text as T +import qualified Omni.Cli as Cli +import Omni.Task.Core +import qualified Omni.Test as Test +import System.Directory (doesFileExist, removeFile) + +main :: IO () +main = Cli.main plan + +plan :: Cli.Plan () +plan = + Cli.Plan + { help = help, + move = move, + test = test, + tidy = \_ -> pure () + } + +help :: Cli.Docopt +help = + [Cli.docopt| +task + +Usage: + task init + task create <title> <project> [--deps=<ids>] + task list [--project=<project>] + task ready + task update <id> <status> + task deps <id> + task export [--flush] + task import -i <file> + task test + task (-h | --help) + +Commands: + init Initialize task database + create Create a new task + list List all tasks + ready Show ready tasks (not blocked) + update Update task status + deps Show dependency tree + export Export and consolidate tasks to JSONL + import Import tasks from JSONL file + test Run tests + +Options: + -h --help Show this help + --project=<project> Filter by project + --deps=<ids> Comma-separated list of dependency IDs + --flush Force immediate export + -i <file> Input file for import + +Arguments: + <title> Task title + <project> Project name + <id> Task ID + <status> Task status (open, in-progress, done) + <file> JSONL file to import +|] + +move :: Cli.Arguments -> IO () +move args + | args `Cli.has` Cli.command "init" = initTaskDb + | args `Cli.has` Cli.command "create" = do + title <- getArgText args "title" + project <- getArgText args "project" + deps <- case Cli.getArg args (Cli.longOption "deps") of + Nothing -> pure [] + Just depStr -> pure <| T.splitOn "," (T.pack depStr) + task <- createTask title project deps + putStrLn <| "Created task: " <> T.unpack (taskId task) + | args `Cli.has` Cli.command "list" = do + maybeProject <- case Cli.getArg args (Cli.longOption "project") of + Nothing -> pure Nothing + Just p -> pure <| Just (T.pack p) + tasks <- listTasks maybeProject + traverse_ printTask tasks + | args `Cli.has` Cli.command "ready" = do + tasks <- getReadyTasks + putText "Ready tasks:" + traverse_ printTask tasks + | args `Cli.has` Cli.command "update" = do + tid <- getArgText args "id" + statusStr <- getArgText args "status" + let newStatus = case statusStr of + "open" -> Open + "in-progress" -> InProgress + "done" -> Done + _ -> panic "Invalid status. Use: open, in-progress, or done" + updateTaskStatus tid newStatus + putStrLn <| "Updated task " <> T.unpack tid + | args `Cli.has` Cli.command "deps" = do + tid <- getArgText args "id" + showDependencyTree tid + | args `Cli.has` Cli.command "export" = do + exportTasks + putText "Exported and consolidated tasks to .tasks/tasks.jsonl" + | args `Cli.has` Cli.command "import" = do + file <- getArgText args "file" + importTasks (T.unpack file) + putText <| "Imported tasks from " <> file + | otherwise = putText (T.pack <| Cli.usage help) + where + getArgText :: Cli.Arguments -> String -> IO Text + getArgText argz name = do + maybeArg <- pure <| Cli.getArg argz (Cli.argument name) + case maybeArg of + Nothing -> panic (T.pack name <> " required") + Just val -> pure (T.pack val) + +test :: Test.Tree +test = Test.group "Omni.Task" [unitTests] + +unitTests :: Test.Tree +unitTests = + Test.group + "Unit tests" + [ Test.unit "can create task" <| do + -- Clean up before test + exists <- doesFileExist ".tasks/tasks.jsonl" + when exists <| removeFile ".tasks/tasks.jsonl" + + initTaskDb + task <- createTask "Test task" "test-project" [] + taskTitle task Test.@?= "Test task" + taskProject task Test.@?= "test-project" + taskStatus task Test.@?= Open + null (taskDependencies task) Test.@?= True, + Test.unit "can list tasks" <| do + tasks <- listTasks Nothing + not (null tasks) Test.@?= True, + Test.unit "ready tasks exclude blocked ones" <| do + task1 <- createTask "First task" "test" [] + task2 <- createTask "Blocked task" "test" [taskId task1] + ready <- getReadyTasks + (taskId task1 `elem` map taskId ready) Test.@?= True + (taskId task2 `notElem` map taskId ready) Test.@?= True + ] 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 |
