summaryrefslogtreecommitdiff
path: root/Omni
diff options
context:
space:
mode:
authorOmni Worker <bot@omni.agent>2025-11-21 04:44:32 -0500
committerOmni Worker <bot@omni.agent>2025-11-21 04:44:32 -0500
commit5f709412759f8c3f7c81f1343fa9a94ece5117d9 (patch)
treea0696d09a500b324515a5184472cc86a79f34f31 /Omni
parent65c0b02b23a8b3125b0c10112d48c1a637f01cf9 (diff)
parent7801b1dc328b07a8589d651d4af843cc6acb4552 (diff)
Merge live into task/t-rWacMb1av
Amp-Thread-ID: https://ampcode.com/threads/T-7109f8d0-feb4-4a24-bc4b-37743227e2cb Co-authored-by: Amp <amp@ampcode.com>
Diffstat (limited to 'Omni')
-rw-r--r--Omni/Agent/Git.hs138
-rw-r--r--Omni/Agent/Log.hs71
-rw-r--r--Omni/Agent/LogTest.hs72
-rw-r--r--Omni/Agent/WORKER_AGENT_GUIDE.md61
-rwxr-xr-xOmni/Agent/harvest-tasks.sh9
-rwxr-xr-xOmni/Agent/setup-worker.sh7
-rw-r--r--Omni/Bild.hs3
-rw-r--r--Omni/Bild/Deps/Haskell.nix1
-rw-r--r--Omni/Task.hs53
-rw-r--r--Omni/Task/Core.hs93
10 files changed, 444 insertions, 64 deletions
diff --git a/Omni/Agent/Git.hs b/Omni/Agent/Git.hs
new file mode 100644
index 0000000..a7afb20
--- /dev/null
+++ b/Omni/Agent/Git.hs
@@ -0,0 +1,138 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Git operations for the agent.
+--
+-- : out omni-agent-git
+-- : dep temporary
+module Omni.Agent.Git
+ ( checkout,
+ main,
+ test,
+ )
+where
+
+import Alpha
+import qualified Data.Text as Text
+import qualified Omni.Log as Log
+import Omni.Test ((@=?))
+import qualified Omni.Test as Test
+import qualified System.Directory as Directory
+import qualified System.Exit as Exit
+import qualified System.IO.Temp as Temp
+import qualified System.Process as Process
+
+main :: IO ()
+main = Test.run test
+
+test :: Test.Tree
+test =
+ Test.group
+ "Omni.Agent.Git"
+ [ Test.unit "checkout works" <| do
+ Temp.withSystemTempDirectory "omni-agent-git-test" <| \tmpDir -> do
+ let repo = tmpDir <> "/repo"
+ Directory.createDirectory repo
+ -- init repo
+ git repo ["init"]
+ git repo ["branch", "-m", "master"]
+ git repo ["config", "user.email", "you@example.com"]
+ git repo ["config", "user.name", "Your Name"]
+
+ -- commit A
+ writeFile (repo <> "/a.txt") "A"
+ git repo ["add", "a.txt"]
+ git repo ["commit", "-m", "A"]
+ shaA <- getSha repo "HEAD"
+
+ -- create branch dev
+ git repo ["checkout", "-b", "dev"]
+
+ -- commit B
+ writeFile (repo <> "/b.txt") "B"
+ git repo ["add", "b.txt"]
+ git repo ["commit", "-m", "B"]
+ shaB <- getSha repo "HEAD"
+
+ -- switch back to master
+ git repo ["checkout", "master"]
+
+ -- Test 1: checkout dev
+ checkout repo "dev"
+ current <- getSha repo "HEAD"
+ shaB @=? current
+
+ -- Test 2: checkout master
+ checkout repo "master"
+ current' <- getSha repo "HEAD"
+ shaA @=? current'
+
+ -- Test 3: dirty state
+ writeFile (repo <> "/a.txt") "DIRTY"
+ checkout repo "dev"
+ current'' <- getSha repo "HEAD"
+ shaB @=? current''
+ -- Verify dirty file is gone/overwritten (b.txt should exist, a.txt should be A from master? No, a.txt is in A and B)
+ -- Wait, in dev, a.txt is "A".
+ content <- readFile (repo <> "/a.txt")
+ "A" @=? content
+
+ -- Test 4: untracked file
+ writeFile (repo <> "/untracked.txt") "DELETE ME"
+ checkout repo "master"
+ exists <- Directory.doesFileExist (repo <> "/untracked.txt")
+ False @=? exists
+ ]
+
+getSha :: FilePath -> String -> IO String
+getSha dir ref = do
+ let cmd = (Process.proc "git" ["rev-parse", ref]) {Process.cwd = Just dir}
+ (code, out, _) <- Process.readCreateProcessWithExitCode cmd ""
+ case code of
+ Exit.ExitSuccess -> pure <| strip out
+ _ -> panic "getSha failed"
+
+-- | Checkout a specific ref (SHA, branch, tag) in the given repository path.
+-- This function ensures the repository is in the correct state by:
+-- 1. Fetching all updates
+-- 2. Checking out the ref (forcing overwrites of local changes)
+-- 3. Resetting hard to the ref (to ensure clean state)
+-- 4. Cleaning untracked files
+-- 5. Updating submodules
+checkout :: FilePath -> Text -> IO ()
+checkout repoPath ref = do
+ let r = Text.unpack ref
+
+ Log.info ["git", "checkout", ref, "in", Text.pack repoPath]
+
+ -- Fetch all refs to ensure we have the target
+ git repoPath ["fetch", "--all", "--tags"]
+
+ -- Checkout the ref, discarding local changes
+ git repoPath ["checkout", "--force", r]
+
+ -- Reset hard to ensure we are exactly at the target state
+ git repoPath ["reset", "--hard", r]
+
+ -- Remove untracked files and directories
+ git repoPath ["clean", "-fdx"]
+
+ -- Update submodules
+ git repoPath ["submodule", "update", "--init", "--recursive"]
+
+ Log.good ["git", "checkout", "complete"]
+ Log.br
+
+-- | Run a git command in the given directory.
+git :: FilePath -> [String] -> IO ()
+git dir args = do
+ let cmd = (Process.proc "git" args) {Process.cwd = Just dir}
+ (exitCode, out, err) <- Process.readCreateProcessWithExitCode cmd ""
+ case exitCode of
+ Exit.ExitSuccess -> pure ()
+ Exit.ExitFailure code -> do
+ Log.fail ["git command failed", Text.pack (show args), "code: " <> show code]
+ Log.info [Text.pack out]
+ Log.info [Text.pack err]
+ Log.br
+ panic <| "git command failed: git " <> show args
diff --git a/Omni/Agent/Log.hs b/Omni/Agent/Log.hs
new file mode 100644
index 0000000..c93479b
--- /dev/null
+++ b/Omni/Agent/Log.hs
@@ -0,0 +1,71 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Omni.Agent.Log
+ ( LogEntry (..),
+ parseLine,
+ format,
+ )
+where
+
+import Alpha
+import Data.Aeson (FromJSON (..), (.:), (.:?))
+import qualified Data.Aeson as Aeson
+import qualified Data.ByteString.Lazy as BSL
+
+data LogEntry = LogEntry
+ { leMessage :: Text,
+ leLevel :: Maybe Text,
+ leToolName :: Maybe Text,
+ leBatches :: Maybe [[Text]],
+ leMethod :: Maybe Text,
+ lePath :: Maybe Text
+ }
+ deriving (Show, Eq, Generic)
+
+instance FromJSON LogEntry where
+ parseJSON =
+ Aeson.withObject "LogEntry" <| \v ->
+ ( LogEntry
+ </ (v .: "message")
+ )
+ <*> v
+ .:? "level"
+ <*> v
+ .:? "toolName"
+ <*> v
+ .:? "batches"
+ <*> v
+ .:? "method"
+ <*> v
+ .:? "path"
+
+parseLine :: Text -> Maybe LogEntry
+parseLine line = Aeson.decode <| BSL.fromStrict <| encodeUtf8 line
+
+format :: LogEntry -> Maybe Text
+format e =
+ case leMessage e of
+ "executing 1 tools in 1 batch(es)" ->
+ let tool = case leBatches e of
+ Just ((t : _) : _) -> t
+ _ -> "unknown"
+ in Just <| "🤖 THOUGHT: Planning tool execution (" <> tool <> ")"
+ "Tool Bash permitted - action: allow" ->
+ Just "🔧 TOOL: Bash command executed"
+ msg
+ | "Processing tool completion for ledger" == msg && isJust (leToolName e) ->
+ Just <| "✅ TOOL: " <> fromMaybe "" (leToolName e) <> " completed"
+ "ide-fs" ->
+ case leMethod e of
+ Just "readFile" -> Just <| "📂 READ: " <> fromMaybe "" (lePath e)
+ _ -> Nothing
+ "System prompt build complete (no changes)" ->
+ Just "🧠 THINKING..."
+ "System prompt build complete (first build)" ->
+ Just "🚀 STARTING new task context"
+ msg ->
+ case leLevel e of
+ Just "error" -> Just <| "❌ ERROR: " <> msg
+ _ -> Nothing
diff --git a/Omni/Agent/LogTest.hs b/Omni/Agent/LogTest.hs
new file mode 100644
index 0000000..0d085b1
--- /dev/null
+++ b/Omni/Agent/LogTest.hs
@@ -0,0 +1,72 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- : out agent-log-test
+module Omni.Agent.LogTest where
+
+import Alpha
+import Omni.Agent.Log
+import qualified Omni.Test as Test
+
+main :: IO ()
+main = Test.run tests
+
+tests :: Test.Tree
+tests =
+ Test.group
+ "Omni.Agent.Log"
+ [ Test.unit "Parse LogEntry" testParse,
+ Test.unit "Format LogEntry" testFormat
+ ]
+
+testParse :: IO ()
+testParse = do
+ let json = "{\"message\": \"executing 1 tools in 1 batch(es)\", \"batches\": [[\"grep\"]]}"
+ let expected =
+ LogEntry
+ { leMessage = "executing 1 tools in 1 batch(es)",
+ leLevel = Nothing,
+ leToolName = Nothing,
+ leBatches = Just [["grep"]],
+ leMethod = Nothing,
+ lePath = Nothing
+ }
+ parseLine json @?= Just expected
+
+testFormat :: IO ()
+testFormat = do
+ let entry =
+ LogEntry
+ { leMessage = "executing 1 tools in 1 batch(es)",
+ leLevel = Nothing,
+ leToolName = Nothing,
+ leBatches = Just [["grep"]],
+ leMethod = Nothing,
+ lePath = Nothing
+ }
+ format entry @?= Just "🤖 THOUGHT: Planning tool execution (grep)"
+
+ let entry2 =
+ LogEntry
+ { leMessage = "some random log",
+ leLevel = Nothing,
+ leToolName = Nothing,
+ leBatches = Nothing,
+ leMethod = Nothing,
+ lePath = Nothing
+ }
+ format entry2 @?= Nothing
+
+ let entry3 =
+ LogEntry
+ { leMessage = "some error",
+ leLevel = Just "error",
+ leToolName = Nothing,
+ leBatches = Nothing,
+ leMethod = Nothing,
+ lePath = Nothing
+ }
+ format entry3 @?= Just "❌ ERROR: some error"
+
+(@?=) :: (Eq a, Show a) => a -> a -> IO ()
+(@?=) = (Test.@?=)
diff --git a/Omni/Agent/WORKER_AGENT_GUIDE.md b/Omni/Agent/WORKER_AGENT_GUIDE.md
index af81bb0..5bae08f 100644
--- a/Omni/Agent/WORKER_AGENT_GUIDE.md
+++ b/Omni/Agent/WORKER_AGENT_GUIDE.md
@@ -55,13 +55,10 @@ task update t-123 in-progress
2. **Check for Unmerged Work**: Look for dependencies that have existing branches (e.g., `task/t-parent-id`) which are NOT yet merged into `live`.
3. **Select Base**:
* If you find an unmerged dependency branch, check it out: `git checkout task/t-parent-id`.
- * Otherwise, start from fresh live code: `git checkout omni-worker-1` (which tracks `live`).
+ * Otherwise, start from fresh live code: `git fetch origin live && git checkout -b task/t-123 origin/live`.
-4. **Create/Checkout Feature Branch**:
- ```bash
- # Try to switch to existing branch, otherwise create new one
- git checkout task/t-123 || git checkout -b task/t-123
- ```
+4. **Implement**:
+ (Proceed to implementation)
### Step 4: Implement
@@ -70,29 +67,35 @@ task update t-123 in-progress
3. **Run Tests**: `bild --test Omni/YourNamespace.hs`
### Step 5: Submit for Review
-
-1. **Commit Implementation**:
- ```bash
- git add .
- git commit -m "feat: implement t-123"
- ```
-
-2. **Signal Review Readiness**:
- The Planner checks the `omni-worker-X` branch for status updates. You must switch back and update the status there.
-
- ```bash
- # Switch to base branch
- git checkout omni-worker-1
-
- # Sync to get latest state (and any manual merges)
- ./Omni/Agent/sync-tasks.sh
-
- # Mark task for review
- task update t-123 review
-
- # Commit this status change to the worker branch
- ./Omni/Agent/sync-tasks.sh --commit
- ```
+
+ 1. **Update Status and Commit**:
+ Bundle the task status update with your implementation to keep history clean.
+
+ ```bash
+ # 1. Mark task for review (updates .tasks/tasks.jsonl)
+ task update t-123 review
+
+ # 2. Commit changes + task update
+ git add .
+ git commit -m "feat: implement t-123"
+ ```
+
+ 2. **Signal Review Readiness**:
+ Update the worker branch to signal the planner.
+
+ ```bash
+ # Switch to base branch
+ git checkout omni-worker-1
+
+ # Sync to get latest state
+ ./Omni/Agent/sync-tasks.sh
+
+ # Ensure the task is marked review here too (for harvest visibility)
+ task update t-123 review
+
+ # Commit this status change to the worker branch
+ ./Omni/Agent/sync-tasks.sh --commit
+ ```
*Note: The Planner will now see 't-123' in 'Review' when it runs `harvest-tasks.sh`.*
diff --git a/Omni/Agent/harvest-tasks.sh b/Omni/Agent/harvest-tasks.sh
index 282beab..44c2322 100755
--- a/Omni/Agent/harvest-tasks.sh
+++ b/Omni/Agent/harvest-tasks.sh
@@ -45,7 +45,14 @@ if [ "$UPDATED" -eq 1 ]; then
# Commit if there are changes
if [[ -n $(git status --porcelain .tasks/tasks.jsonl) ]]; then
git add .tasks/tasks.jsonl
- git commit -m "task: harvest updates from workers"
+
+ LAST_MSG=$(git log -1 --pretty=%s)
+ if [[ "$LAST_MSG" == "task: harvest updates from workers" ]]; then
+ echo "Squashing with previous harvest commit..."
+ git commit --amend --no-edit
+ else
+ git commit -m "task: harvest updates from workers"
+ fi
echo "Success: Task database updated and committed."
else
echo "No effective changes found."
diff --git a/Omni/Agent/setup-worker.sh b/Omni/Agent/setup-worker.sh
index 28c29b1..42b7fc9 100755
--- a/Omni/Agent/setup-worker.sh
+++ b/Omni/Agent/setup-worker.sh
@@ -22,3 +22,10 @@ if [ -f "$REPO_ROOT/.envrc.local" ]; then
echo "Copying .envrc.local..."
cp "$REPO_ROOT/.envrc.local" "$WORKTREE_PATH/"
fi
+
+# Configure git identity for the worker
+echo "Configuring git identity for worker..."
+git -C "$WORKTREE_PATH" config user.name "Omni Worker"
+git -C "$WORKTREE_PATH" config user.email "bot@omni.agent"
+
+echo "Worker setup complete at $WORKTREE_PATH"
diff --git a/Omni/Bild.hs b/Omni/Bild.hs
index 53d96a5..ba54c93 100644
--- a/Omni/Bild.hs
+++ b/Omni/Bild.hs
@@ -653,7 +653,8 @@ analyzeAll isPlanMode nss = do
contentLines
|> Meta.detectAll "--"
|> \Meta.Parsed {..} ->
- detectHaskellImports mempty contentLines +> \(langdeps, srcs) -> do
+ detectHaskellImports mempty contentLines +> \(autoDeps, srcs) -> do
+ let langdeps = autoDeps <> pdep
graph <- buildHsModuleGraph namespace quapath srcs
pure
<| Just
diff --git a/Omni/Bild/Deps/Haskell.nix b/Omni/Bild/Deps/Haskell.nix
index 5d6abbb..6930860 100644
--- a/Omni/Bild/Deps/Haskell.nix
+++ b/Omni/Bild/Deps/Haskell.nix
@@ -54,6 +54,7 @@
"tasty"
"tasty-hunit"
"tasty-quickcheck"
+ "temporary"
"text"
"time"
"transformers"
diff --git a/Omni/Task.hs b/Omni/Task.hs
index 4a36dcf..01b5ad9 100644
--- a/Omni/Task.hs
+++ b/Omni/Task.hs
@@ -45,7 +45,8 @@ Usage:
task update <id> <status> [--json]
task deps <id> [--json]
task tree [<id>] [--json]
- task stats [--json]
+ task progress <id> [--json]
+ task stats [--epic=<id>] [--json]
task export [--flush]
task import -i <file>
task sync
@@ -61,6 +62,7 @@ Commands:
update Update task status
deps Show dependency tree
tree Show task tree (epics with children, or all epics if no ID given)
+ progress Show progress for an epic
stats Show task statistics
export Export and consolidate tasks to JSONL
import Import tasks from JSONL file
@@ -73,6 +75,7 @@ Options:
--parent=<id> Parent epic ID
--priority=<p> Priority: 0-4 (0=critical, 4=backlog, default: 2)
--status=<status> Filter by status: open, in-progress, review, done
+ --epic=<id> Filter stats by epic (recursive)
--deps=<ids> Comma-separated list of dependency IDs
--dep-type=<type> Dependency type: blocks, discovered-from, parent-child, related (default: blocks)
--discovered-from=<id> Shortcut for --deps=<id> --dep-type=discovered-from
@@ -231,12 +234,22 @@ move args
tree <- getTaskTree maybeId
outputJson tree
else showTaskTree maybeId
+ | args `Cli.has` Cli.command "progress" = do
+ tid <- getArgText args "id"
+ if isJsonMode args
+ then do
+ progress <- getTaskProgress tid
+ outputJson progress
+ else showTaskProgress tid
| args `Cli.has` Cli.command "stats" = do
+ maybeEpic <- case Cli.getArg args (Cli.longOption "epic") of
+ Nothing -> pure Nothing
+ Just e -> pure <| Just (T.pack e)
if isJsonMode args
then do
- stats <- getTaskStats
+ stats <- getTaskStats maybeEpic
outputJson stats
- else showTaskStats
+ else showTaskStats maybeEpic
| args `Cli.has` Cli.command "export" = do
exportTasks
putText "Exported and consolidated tasks to .tasks/tasks.jsonl"
@@ -336,20 +349,21 @@ unitTests =
child1 <- createTask "Child 1" WorkTask (Just (taskId parent)) Nothing P2 []
-- Manually create a task with .3 suffix to simulate a gap (or deleted task)
let child3Id = taskId parent <> ".3"
- child3 = Task
- { taskId = child3Id,
- taskTitle = "Child 3",
- taskType = WorkTask,
- taskParent = Just (taskId parent),
- taskNamespace = Nothing,
- taskStatus = Open,
- taskPriority = P2,
- taskDependencies = [],
- taskCreatedAt = taskCreatedAt child1,
- taskUpdatedAt = taskUpdatedAt child1
- }
+ child3 =
+ Task
+ { taskId = child3Id,
+ taskTitle = "Child 3",
+ taskType = WorkTask,
+ taskParent = Just (taskId parent),
+ taskNamespace = Nothing,
+ taskStatus = Open,
+ taskPriority = P2,
+ taskDependencies = [],
+ taskCreatedAt = taskCreatedAt child1,
+ taskUpdatedAt = taskUpdatedAt child1
+ }
saveTask child3
-
+
-- Create a new child, it should get .4, not .2
child4 <- createTask "Child 4" WorkTask (Just (taskId parent)) Nothing P2 []
taskId child4 Test.@?= taskId parent <> ".4",
@@ -525,6 +539,13 @@ cliTests =
Right args -> do
args `Cli.has` Cli.command "stats" Test.@?= True
args `Cli.has` Cli.longOption "json" Test.@?= True,
+ Test.unit "stats with --epic flag" <| do
+ let result = Docopt.parseArgs help ["stats", "--epic=t-abc123"]
+ case result of
+ Left err -> Test.assertFailure <| "Failed to parse 'stats --epic': " <> show err
+ Right args -> do
+ args `Cli.has` Cli.command "stats" Test.@?= True
+ Cli.getArg args (Cli.longOption "epic") Test.@?= Just "t-abc123",
Test.unit "create with flags in different order" <| do
let result = Docopt.parseArgs help ["create", "Test", "--json", "--priority=1", "--namespace=Omni/Task"]
case result of
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
index 2b00bca..3f665da 100644
--- a/Omni/Task/Core.hs
+++ b/Omni/Task/Core.hs
@@ -57,6 +57,14 @@ data DependencyType
| Related -- Soft relationship, doesn't block
deriving (Show, Eq, Generic)
+data TaskProgress = TaskProgress
+ { progressTaskId :: Text,
+ progressTotal :: Int,
+ progressCompleted :: Int,
+ progressPercentage :: Int
+ }
+ deriving (Show, Eq, Generic)
+
instance ToJSON TaskType
instance FromJSON TaskType
@@ -87,7 +95,11 @@ matchesId id1 id2 = T.toLower id1 == T.toLower id2
-- | Find a task by ID (case-insensitive)
findTask :: Text -> [Task] -> Maybe Task
-findTask tid tasks = List.find (\t -> matchesId (taskId t) tid) tasks
+findTask tid = List.find (\t -> matchesId (taskId t) tid)
+
+instance ToJSON TaskProgress
+
+instance FromJSON TaskProgress
-- Get the tasks database file path (use test file if TASK_TEST_MODE is set)
getTasksFilePath :: IO FilePath
@@ -317,6 +329,32 @@ getDependencyTree tid = do
deps = filter (\t -> any (matchesId (taskId t)) depIds) allTasks
in task : concatMap (collectDeps allTasks) deps
+-- Get task progress
+getTaskProgress :: Text -> IO TaskProgress
+getTaskProgress tid = do
+ tasks <- loadTasks
+ -- Verify task exists (optional, but good for error handling)
+ case filter (\t -> taskId t == tid) tasks of
+ [] -> panic "Task not found"
+ _ -> do
+ let children = filter (\child -> taskParent child == Just tid) tasks
+ total = length children
+ completed = length <| filter (\child -> taskStatus child == Done) children
+ percentage = if total == 0 then 0 else (completed * 100) `div` total
+ pure
+ TaskProgress
+ { progressTaskId = tid,
+ progressTotal = total,
+ progressCompleted = completed,
+ progressPercentage = percentage
+ }
+
+-- Show task progress
+showTaskProgress :: Text -> IO ()
+showTaskProgress tid = do
+ progress <- getTaskProgress tid
+ putText <| "Progress for " <> tid <> ": " <> T.pack (show (progressCompleted progress)) <> "/" <> T.pack (show (progressTotal progress)) <> " (" <> T.pack (show (progressPercentage progress)) <> "%)"
+
-- Show dependency tree for a task
showDependencyTree :: Text -> IO ()
showDependencyTree tid = do
@@ -349,7 +387,7 @@ getTaskTree maybeId = do
where
collectChildren :: [Task] -> Task -> [Task]
collectChildren allTasks task =
- let children = filter (\t -> maybe False (`matchesId` taskId task) (taskParent t)) allTasks
+ let children = filter (maybe False (`matchesId` taskId task) <. taskParent) allTasks
in task : concatMap (collectChildren allTasks) children
-- Show task tree (epic with children, or all epics if no ID given)
@@ -377,7 +415,7 @@ showTaskTree maybeId = do
printTreeNode' :: [Task] -> Task -> Int -> [Bool] -> IO ()
printTreeNode' allTasks task indent ancestry = do
- let children = filter (\t -> maybe False (`matchesId` taskId task) (taskParent t)) allTasks
+ let children = filter (maybe False (`matchesId` taskId task) <. taskParent) allTasks
-- Build tree prefix using box-drawing characters
prefix =
if indent == 0
@@ -426,7 +464,7 @@ printTask t = do
let progressInfo =
if taskType t == Epic
then
- let children = filter (\child -> maybe False (`matchesId` taskId t) (taskParent child)) tasks
+ let children = filter (maybe False (`matchesId` taskId t) <. taskParent) tasks
total = length children
completed = length <| filter (\child -> taskStatus child == Done) children
in " [" <> T.pack (show completed) <> "/" <> T.pack (show total) <> "]"
@@ -464,7 +502,7 @@ showTaskDetailed t = do
-- Show epic progress if this is an epic
when (taskType t == Epic) <| do
- let children = filter (\child -> maybe False (`matchesId` taskId t) (taskParent child)) tasks
+ let children = filter (maybe False (`matchesId` taskId t) <. taskParent) tasks
total = length children
completed = length <| filter (\child -> taskStatus child == Done) children
percentage = if total == 0 then 0 else (completed * 100) `div` total
@@ -526,18 +564,31 @@ instance ToJSON TaskStats
instance FromJSON TaskStats
-- Get task statistics
-getTaskStats :: IO TaskStats
-getTaskStats = do
- tasks <- loadTasks
- ready <- getReadyTasks
- let total = length tasks
+getTaskStats :: Maybe Text -> IO TaskStats
+getTaskStats maybeEpicId = do
+ allTasks <- loadTasks
+
+ targetTasks <- case maybeEpicId of
+ Nothing -> pure allTasks
+ Just epicId ->
+ case findTask epicId allTasks of
+ Nothing -> panic "Epic not found"
+ Just task -> pure <| getAllDescendants allTasks (taskId task)
+
+ globalReady <- getReadyTasks
+ let readyIds = map taskId globalReady
+ -- Filter ready tasks to only include those in our target set
+ readyCount = length <| filter (\t -> taskId t `elem` readyIds) targetTasks
+
+ tasks = targetTasks
+ total = length tasks
open = length <| filter (\t -> taskStatus t == Open) tasks
inProg = length <| filter (\t -> taskStatus t == InProgress) tasks
review = length <| filter (\t -> taskStatus t == Review) tasks
done = length <| filter (\t -> taskStatus t == Done) tasks
epics = length <| filter (\t -> taskType t == Epic) tasks
- readyCount = length ready
- blockedCount = total - readyCount - done
+ readyCount' = readyCount
+ blockedCount = total - readyCount' - done
-- Count tasks by priority
byPriority =
[ (P0, length <| filter (\t -> taskPriority t == P0) tasks),
@@ -558,18 +609,26 @@ getTaskStats = do
reviewTasks = review,
doneTasks = done,
totalEpics = epics,
- readyTasks = readyCount,
+ readyTasks = readyCount',
blockedTasks = blockedCount,
tasksByPriority = byPriority,
tasksByNamespace = byNamespace
}
+-- Helper to get all descendants of a task (recursive)
+getAllDescendants :: [Task] -> Text -> [Task]
+getAllDescendants allTasks parentId =
+ let children = filter (maybe False (`matchesId` parentId) <. taskParent) allTasks
+ in children ++ concatMap (getAllDescendants allTasks <. taskId) children
+
-- Show task statistics (human-readable)
-showTaskStats :: IO ()
-showTaskStats = do
- stats <- getTaskStats
+showTaskStats :: Maybe Text -> IO ()
+showTaskStats maybeEpicId = do
+ stats <- getTaskStats maybeEpicId
putText ""
- putText "Task Statistics"
+ case maybeEpicId of
+ Nothing -> putText "Task Statistics"
+ Just epicId -> putText <| "Task Statistics for Epic " <> epicId
putText ""
putText <| "Total tasks: " <> T.pack (show (totalTasks stats))
putText <| " Open: " <> T.pack (show (openTasks stats))