diff options
| -rw-r--r-- | .gitignore | 16 | ||||
| -rw-r--r-- | .tasks/tasks.jsonl | 12 | ||||
| -rw-r--r-- | AGENTS.md | 391 | ||||
| -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 |
9 files changed, 814 insertions, 0 deletions
@@ -17,3 +17,19 @@ dist* .direnv/ Biz/Mynion/Prompt.md .aider* + +# Node.js +node_modules/ +package-lock.json +package.json + +# Task tracker +.tasks/*.db +.tasks/*.db-journal +.tasks/*.sock +.tasks/*.pipe +.tasks/*.log + +# But DO track the JSONL file +!.tasks/ +!.tasks/tasks.jsonl diff --git a/.tasks/tasks.jsonl b/.tasks/tasks.jsonl new file mode 100644 index 0000000..3c975e8 --- /dev/null +++ b/.tasks/tasks.jsonl @@ -0,0 +1,12 @@ +{"taskCreatedAt":"2025-11-08T20:03:50.230851965Z","taskDependencies":[],"taskId":"t-a1b2c3","taskProject":"task-manager","taskStatus":"Done","taskTitle":"Show help text when task invoked without args","taskUpdatedAt":"2025-11-08T20:06:02.605878048Z"} +{"taskCreatedAt":"2025-11-08T20:03:53.429072631Z","taskDependencies":[],"taskId":"t-d4e5f6","taskProject":"task-manager","taskStatus":"Done","taskTitle":"Move dev instructions from README.md to AGENTS.md","taskUpdatedAt":"2025-11-08T20:06:22.732392229Z"} +{"taskCreatedAt":"2025-11-08T20:06:27.395834401Z","taskDependencies":[],"taskId":"t-g7h8i9","taskProject":"task-manager","taskStatus":"Done","taskTitle":"Task ids should be shorter. Use the sqids package in haskell to generate ids","taskUpdatedAt":"2025-11-08T21:00:37.311865046Z"} +{"taskCreatedAt":"2025-11-08T20:09:35.590622249Z","taskDependencies":[],"taskId":"t-j0k1L2","taskProject":"task-manager","taskStatus":"Open","taskTitle":"Tasks should have an optional namespace associated with them. Namespaces are first class citizens in this monorepo","taskUpdatedAt":"2025-11-08T20:09:35.590622249Z"} +{"taskCreatedAt":"2025-11-08T20:10:09.944217463Z","taskDependencies":[],"taskId":"t-m3n4o5","taskProject":"task-manager","taskStatus":"Open","taskTitle":"There should be a command to list all projects.","taskUpdatedAt":"2025-11-08T20:10:09.944217463Z"} +{"taskCreatedAt":"2025-11-08T20:20:38.785442739Z","taskDependencies":[],"taskId":"t-p6q7r8","taskProject":"task-manager","taskStatus":"Done","taskTitle":"Instruct agents too use git-branchless and a patch based workflow rather than traditional git commands if and when they need to record things in git.","taskUpdatedAt":"2025-11-08T21:09:06.854871964Z"} +{"taskCreatedAt":"2025-11-08T20:22:20.116289616Z","taskDependencies":[],"taskId":"t-s9T0u1","taskProject":"task-manager","taskStatus":"Open","taskTitle":"instruct agents to include tests with all new features and bug fixes","taskUpdatedAt":"2025-11-08T20:22:20.116289616Z"} +{"taskCreatedAt":"2025-11-08T20:45:12.764939794Z","taskDependencies":[],"taskId":"t-v2w3x4","taskProject":"task-manager","taskStatus":"Open","taskTitle":"instruct agents to run 'bild --test' and 'lint' for whatever namespace(s) they are working on after completing a task and fix any reported errors","taskUpdatedAt":"2025-11-08T20:45:12.764939794Z"} +{"taskCreatedAt":"2025-11-08T20:48:43.183226361Z","taskDependencies":[],"taskId":"t-y5z6A7","taskProject":"ide","taskStatus":"Open","taskTitle":"The script Omni/Ide/typecheck.sh needs to support Haskell type checking in a similar fashion as how Omni/Ide/repl.sh is able to handle multiple languages","taskUpdatedAt":"2025-11-08T20:48:43.183226361Z"} +{"taskCreatedAt":"2025-11-08T21:00:27.020241869Z","taskDependencies":[],"taskId":"t-1ky7gJ2","taskProject":"task-manager","taskStatus":"Done","taskTitle":"Test shorter IDs","taskUpdatedAt":"2025-11-08T21:04:00.990704969Z"} +{"taskCreatedAt":"2025-11-08T21:00:29.901677247Z","taskDependencies":[],"taskId":"t-1kyjmjN","taskProject":"task-manager","taskStatus":"Done","taskTitle":"Another test task","taskUpdatedAt":"2025-11-08T21:04:04.081664205Z"} +{"taskCreatedAt":"2025-11-08T21:11:41.013924674Z","taskDependencies":[],"taskId":"t-1lhJhgS","taskProject":"misc","taskStatus":"Open","taskTitle":"Remove the old aider config in .aider* files and directories. Aider stinks and we will use amp going forward","taskUpdatedAt":"2025-11-08T21:11:41.013924674Z"} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e30b01a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,391 @@ +# Task Manager for AI Agents + +This document describes the task tracking system for AI coding agents working in this codebase. + +## Overview + +The task manager is a dependency-aware issue tracker inspired by beads. It uses: +- **Storage**: Local JSONL file (`.tasks/tasks.jsonl`) +- **Sync**: Git-tracked (automatically synced across machines) +- **Dependencies**: Tasks can block other tasks +- **Ready work detection**: Automatically finds unblocked tasks + +## Quick Reference + +### Create a Task +```bash +task create "<title>" <project> [--deps=<ids>] +``` + +Examples: +```bash +task create "Add authentication" auth-system +task create "Write tests" auth-system --deps=t-20241108120000 +``` + +### List Tasks +```bash +task list [--project=<project>] +``` + +Examples: +```bash +task list # All tasks +task list --project=auth # Filter by project +``` + +### Get Ready Work +```bash +task ready +``` + +Shows all tasks that are: +- Not closed +- Not blocked by incomplete dependencies + +### Update Task Status +```bash +task update <id> <status> +``` + +Status values: `open`, `in-progress`, `done` + +Examples: +```bash +task update t-20241108120000 in-progress +task update t-20241108120000 done +``` + +### View Dependencies +```bash +task deps <id> +``` + +Shows the dependency tree for a task. + +### Export Tasks +```bash +task export [--flush] +``` + +Consolidates and exports tasks to `.tasks/tasks.jsonl`, removing duplicates. The `--flush` flag forces immediate export (used by git hooks). + +### Import Tasks +```bash +task import -i <file> +``` + +Imports tasks from a JSONL file, merging with existing tasks. Newer tasks (based on `updatedAt` timestamp) take precedence. + +Examples: +```bash +task import -i .tasks/tasks.jsonl +task import -i /path/to/backup.jsonl +``` + +### Initialize (First Time) +```bash +task init +``` + +Creates `.tasks/` directory and `tasks.jsonl` file. + +## Common Workflows + +### Starting New Work + +1. **Find what's ready to work on:** + ```bash + task ready + ``` + +2. **Pick a task and mark it in progress:** + ```bash + task update t-20241108120000 in-progress + ``` + +3. **When done, mark it complete:** + ```bash + task update t-20241108120000 done + ``` + +### Creating Dependent Tasks + +When you discover work that depends on other work: + +```bash +# Create the blocking task first +task create "Design API" api-layer + +# Note the ID (e.g., t-20241108120000) + +# Create dependent task +task create "Implement API client" api-layer --deps=t-20241108120000 +``` + +The dependent task won't show up in `task ready` until the blocker is marked `done`. + +### Working on a Project + +```bash +# See all tasks for a project +task list --project=auth-system + +# Create related tasks +task create "Design login flow" auth-system +task create "Implement OAuth" auth-system +task create "Add password reset" auth-system +``` + +### Breaking Down Large Work + +```bash +# Create parent task +task create "Complete authentication system" auth-system +# Note ID: t-20241108120000 + +# Create subtasks that depend on planning +task create "Backend auth service" auth-system --deps=t-20241108120000 +task create "Frontend login UI" auth-system --deps=t-20241108120000 +task create "Integration tests" auth-system --deps=t-20241108120000 +``` + +## Agent Best Practices + +### 1. Always Check Ready Work First +Before asking what to do, check `task ready` to see unblocked tasks. + +### 2. Create Tasks for Discovered Work +When you encounter work during implementation: +```bash +task create "Fix type error in auth module" auth-system +task create "Add missing test coverage" testing +``` + +### 3. Track Dependencies +If work depends on other work, use `--deps`: +```bash +# Can't write tests until implementation is done +task create "Test auth flow" testing --deps=t-20241108120000 +``` + +### 4. Use Descriptive Titles +Good: `"Add JWT token validation to auth middleware"` +Bad: `"Fix auth"` + +### 5. Keep Projects Organized +Use consistent project names: +- `auth-system` not `auth`, `authentication`, `auth-system-v2` +- `api-layer` not `api`, `API`, `backend-api` + +## Task Lifecycle + +``` +[open] ──update in-progress──> [in-progress] ──update done──> [done] + │ │ + └─────────update open───────────────┘ +``` + +States: +- **open**: Not started, available for work (if not blocked) +- **in-progress**: Currently being worked on +- **done**: Completed + +## Dependency Rules + +- A task is **blocked** if any of its dependencies are not `done` +- A task is **ready** if all its dependencies are `done` (or it has no dependencies) +- `task ready` only shows tasks with status `open` or `in-progress` that are not blocked + +## File Structure + +``` +.tasks/ +├── tasks.jsonl # Git-tracked, source of truth +``` + +Each line in `tasks.jsonl` is a JSON object representing a task. + +## Example Session + +```bash +# First time setup +task init + +# Create some work +task create "Design task manager schema" core-system +task create "Implement JSONL storage" core-system +task create "Add dependency tracking" core-system + +# See what's ready (all of them, no blockers yet) +task ready + +# Start working +task update t-20241108120000 in-progress + +# Discover dependent work +task create "Write storage tests" testing --deps=t-20241108120000 + +# Complete first task +task update t-20241108120000 done + +# Now the test task is unblocked +task ready +# Shows: "Write storage tests" +``` + +## Build and Test Commands + +Build the task manager: +```bash +bild --time 0 Omni/Task.hs +``` + +Run tests: +```bash +task test +``` + +## Integration with Git + +The `.tasks/tasks.jsonl` file is git-tracked. When you: +- Create/update tasks locally +- Commit and push +- Other machines/agents get the updates on `git pull` + +**Important**: Add to `.gitignore`: +``` +.tasks/*.db +.tasks/*.db-journal +.tasks/*.sock +``` + +But **do** track: +``` +!.tasks/ +!.tasks/tasks.jsonl +``` + +## Troubleshooting + +### "Task not found" +- Check the task ID is correct with `task list` +- Ensure you've run `task init` + +### "Database not initialized" +Run: `task init` + +### Dependencies not working +- Verify dependency IDs exist: `task list` +- Check dependency tree: `task deps <id>` + +## Development Tools + +### bild + +`bild` is the universal build tool. It can build and test everything in the repo. + +Examples: +```bash +bild --test Omni/Bild.hs # Build and test a namespace +bild --time 0 Omni/Cloud.nix # Build with no timeout +bild --plan Omni/Test.hs # Analyze build without building +``` + +### lint + +Universal lint and formatting tool. Errors if lints fail or code is not formatted properly. + +Examples: +```bash +lint Omni/Cli.hs # Lint a namespace +lint --fix **/*.py # Lint and fix all Python files +``` + +### repl.sh + +Like `nix-shell` but specific to this repo. Analyzes the namespace, pulls dependencies, and starts a shell or repl. + +Examples: +```bash +repl.sh Omni/Bild.hs # Start Haskell repl with namespace loaded +repl.sh --bash Omni/Log.py # Start bash shell for namespace +``` + +## Coding Conventions + +1. **Test interface**: Every program must accept `test` as a first argument to run its test suite +2. **Entrypoint naming**: The entrypoint for every program shall be called `main` + +## Git Workflow + +### Use git-branchless + +This repository uses **git-branchless** for a patch-based workflow instead of traditional branch-based git. + +Key concepts: +- Work with **patches** (commits) directly rather than branches +- Use **stacking** to organize related changes +- Leverage **smartlog** to visualize commit history + +### Common git-branchless Commands + +**View commit graph:** +```bash +git smartlog +``` + +**Create a new commit:** +```bash +# Make your changes +git add . +git commit -m "Your commit message" +``` + +**Amend the current commit:** +```bash +# Make additional changes +git add . +git commit --amend --no-edit +``` + +**Move/restack commits:** +```bash +git move -s <source> -d <destination> +git restack +``` + +**Submit changes:** +```bash +# After commits are ready +git submit +``` + +### When to Record Changes in Git + +**DO record in git:** +- Completed features or bug fixes +- Working code that passes tests and linting +- Significant milestones in task completion + +**DO NOT record in git:** +- Work in progress (unless specifically requested) +- Broken or untested code +- Temporary debugging changes + +### Workflow Best Practices + +1. **Make small, focused commits** - Each commit should represent one logical change +2. **Write descriptive commit messages** - Explain what and why, not just what +3. **Rebase and clean up history** - Use `git commit --amend` and `git restack` to keep history clean +4. **Test before committing** - Run `bild --test` and `lint` on affected namespaces + +## Future Enhancements + +Planned features (not yet implemented): +- Task priorities +- Due dates +- Task labels/tags +- Search/filter capabilities +- Assignees +- Multi-repo support 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 |
