summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.tasks/tasks.jsonl9
-rw-r--r--Omni/Agent/Log.hs213
-rw-r--r--Omni/Agent/Worker.hs99
-rwxr-xr-xOmni/Agent/start-worker.sh6
-rw-r--r--Omni/Ci.hs193
-rwxr-xr-xOmni/Ci.sh65
-rw-r--r--Omni/Task.hs28
-rw-r--r--Omni/Task/Core.hs9
8 files changed, 365 insertions, 257 deletions
diff --git a/.tasks/tasks.jsonl b/.tasks/tasks.jsonl
index aa8c1da..cd4dd0a 100644
--- a/.tasks/tasks.jsonl
+++ b/.tasks/tasks.jsonl
@@ -202,8 +202,9 @@
{"taskCreatedAt":"2025-11-22T10:39:11.364170862Z","taskDependencies":[{"depId":"t-rwbmpxabk","depType":"DiscoveredFrom"}],"taskDescription":null,"taskId":"t-rwcm6todb","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Fix failing tests in Biz/PodcastItLater/Web.py (UsageLimits and EpisodeDetail)","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T14:32:24.762100815Z"}
{"taskCreatedAt":"2025-11-22T20:37:09.166630362Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1o2bxcq7999","taskNamespace":"Omni/Workflow.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Phase 1: Foundations (Task & CI)","taskType":"Epic","taskUpdatedAt":"2025-11-22T20:37:09.166630362Z"}
{"taskCreatedAt":"2025-11-22T20:37:13.980489314Z","taskDependencies":[],"taskDescription":"Configure .gitattributes and .git/config (via Omni/Ide/hooks or setup) to use 'agent merge-driver' for .tasks/tasks.jsonl. This prevents data loss when merging branches with divergent task lists.","taskId":"t-1o2bxcq7999.1","taskNamespace":"Omni/Ide.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P0","taskStatus":"Done","taskTitle":"Configure git merge driver for tasks.jsonl","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:57:23.592078308Z"}
-{"taskCreatedAt":"2025-11-22T20:37:18.719690905Z","taskDependencies":[],"taskDescription":"Update Task Core to include Approved status, update CLI to support it, update TaskStats, and fix any compilation errors. Reference plan: /home/ben/omni/_/llm/PLAN_Autonomous_Workflow.md","taskId":"t-1o2bxcq7999.2","taskNamespace":"Omni/Task.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P1","taskStatus":"Review","taskTitle":"Add Approved status to Omni/Task","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:21:07.384080767Z"}
-{"taskCreatedAt":"2025-11-22T20:37:23.378739333Z","taskDependencies":[],"taskDescription":"Rewrite Omni/Ci.sh into a robust Haskell program (Omni/Ci.hs). Reference plan: /home/ben/omni/_/llm/PLAN_Autonomous_Workflow.md","taskId":"t-1o2bxcq7999.3","taskNamespace":"Omni/Ci.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P1","taskStatus":"Review","taskTitle":"Implement Omni/Ci.hs","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:23:53.72628323Z"}
-{"taskCreatedAt":"2025-11-22T20:37:27.396872011Z","taskDependencies":[],"taskDescription":"The Time, Thread, and Credits fields in the agent status bar are not being populated. Update Omni/Agent/Log.hs to parse these fields from the JSON log output.","taskId":"t-1o2bxd11zv9","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P1","taskStatus":"Review","taskTitle":"Fix missing Time, Thread, and Credits in Agent Log","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:31:55.395825865Z"}
+{"taskCreatedAt":"2025-11-22T20:37:18.719690905Z","taskDependencies":[],"taskDescription":"Update Task Core to include Approved status, update CLI to support it, update TaskStats, and fix any compilation errors. Reference plan: /home/ben/omni/_/llm/PLAN_Autonomous_Workflow.md","taskId":"t-1o2bxcq7999.2","taskNamespace":"Omni/Task.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P1","taskStatus":"Done","taskTitle":"Add Approved status to Omni/Task","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:59:08.985299564Z"}
+{"taskCreatedAt":"2025-11-22T20:37:23.378739333Z","taskDependencies":[],"taskDescription":"Rewrite Omni/Ci.sh into a robust Haskell program (Omni/Ci.hs). Reference plan: /home/ben/omni/_/llm/PLAN_Autonomous_Workflow.md","taskId":"t-1o2bxcq7999.3","taskNamespace":"Omni/Ci.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P1","taskStatus":"Done","taskTitle":"Implement Omni/Ci.hs","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T22:01:09.779228442Z"}
+{"taskCreatedAt":"2025-11-22T20:37:27.396872011Z","taskDependencies":[],"taskDescription":"The Time, Thread, and Credits fields in the agent status bar are not being populated. Update Omni/Agent/Log.hs to parse these fields from the JSON log output.","taskId":"t-1o2bxd11zv9","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P1","taskStatus":"Done","taskTitle":"Fix missing Time, Thread, and Credits in Agent Log","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T22:02:50.438643714Z"}
{"taskCreatedAt":"2025-11-22T20:37:31.615764727Z","taskDependencies":[],"taskDescription":"The 'task ready' command currently lists Epics. Update 'getReadyTasks' in Omni/Task/Core.hs to exclude tasks where taskType == Epic.","taskId":"t-1o2bxd3kezj","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P1","taskStatus":"Review","taskTitle":"Fix task ready to exclude Epics","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:34:28.577198837Z"}
-{"taskCreatedAt":"2025-11-22T21:45:10.578083608Z","taskDependencies":[],"taskDescription":"Update Omni/Agent/start-worker.sh to run 'git sync' in the worker directory before building 'task' and 'agent'. This ensures the worker has the latest tools and code from live.","taskId":"t-1o2bxcq7999.4","taskNamespace":"Omni/Agent.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P1","taskStatus":"Review","taskTitle":"Sync worker repo in start-worker.sh","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:46:40.928370623Z"}
+{"taskCreatedAt":"2025-11-22T21:45:10.578083608Z","taskDependencies":[],"taskDescription":"Update Omni/Agent/start-worker.sh to run 'git sync' in the worker directory before building 'task' and 'agent'. This ensures the worker has the latest tools and code from live.","taskId":"t-1o2bxcq7999.4","taskNamespace":"Omni/Agent.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P1","taskStatus":"Done","taskTitle":"Sync worker repo in start-worker.sh","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T22:01:47.245671772Z"}
+{"taskCreatedAt":"2025-11-22T21:19:54.675769476Z","taskDependencies":[],"taskDescription":null,"taskId":"t-rwd249bi3","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Test Approved Status","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:20:10.652509625Z"}
diff --git a/Omni/Agent/Log.hs b/Omni/Agent/Log.hs
index dd66abc..07770d0 100644
--- a/Omni/Agent/Log.hs
+++ b/Omni/Agent/Log.hs
@@ -1,4 +1,3 @@
-{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoImplicitPrelude #-}
@@ -7,34 +6,22 @@
module Omni.Agent.Log where
import Alpha
-import Data.Aeson (Value (..), decode)
-import qualified Data.Aeson.KeyMap as KM
+import Data.Aeson ((.:), (.:?))
+import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy as BL
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
-import qualified Data.Text.Encoding as TextEnc
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as TE
import qualified Data.Text.IO as TIO
-import qualified Data.Vector as V
import qualified System.Console.ANSI as ANSI
import qualified System.IO as IO
import System.IO.Unsafe (unsafePerformIO)
-import Text.Printf (printf)
-
--- | Parsed log entry
-data LogEntry = LogEntry
- { leMessage :: Maybe Text,
- leLevel :: Maybe Text,
- leToolName :: Maybe Text,
- leBatches :: Maybe [[Text]],
- leMethod :: Maybe Text,
- lePath :: Maybe Text
- }
- deriving (Show, Eq)
-- | Status of the agent for the UI
data Status = Status
{ statusWorker :: Text,
statusTask :: Maybe Text,
- statusThreadId :: Maybe Text,
+ statusThread :: Maybe Text,
statusFiles :: Int,
statusCredits :: Double,
statusTime :: Text, -- formatted time string
@@ -47,7 +34,7 @@ emptyStatus workerName =
Status
{ statusWorker = workerName,
statusTask = Nothing,
- statusThreadId = Nothing,
+ statusThread = Nothing,
statusFiles = 0,
statusCredits = 0.0,
statusTime = "00:00",
@@ -64,9 +51,10 @@ init :: Text -> IO ()
init workerName = do
IO.hSetBuffering IO.stderr IO.LineBuffering
writeIORef currentStatus (emptyStatus workerName)
- -- Reserve 5 lines at bottom
- replicateM_ 5 (IO.hPutStrLn IO.stderr "")
- ANSI.hCursorUp IO.stderr 5
+ -- Reserve 2 lines at bottom
+ IO.hPutStrLn IO.stderr ""
+ IO.hPutStrLn IO.stderr ""
+ ANSI.hCursorUp IO.stderr 2
-- | Update the status
update :: (Status -> Status) -> IO ()
@@ -78,96 +66,14 @@ update f = do
updateActivity :: Text -> IO ()
updateActivity msg = update (\s -> s {statusActivity = msg})
--- | Process a log line from the agent and update status if relevant
-processLogLine :: Text -> IO ()
-processLogLine line = do
- let entry = parseLine line
- for_ (entry +> formatLogEntry) updateActivity
-
--- | Parse a JSON log line into a LogEntry
-parseLine :: Text -> Maybe LogEntry
-parseLine line = do
- let lbs = BL.fromStrict (TextEnc.encodeUtf8 line)
- obj <- decode lbs
- case obj of
- Object o ->
- Just
- LogEntry
- { leMessage = getString "message" o,
- leLevel = getString "level" o,
- leToolName = getString "toolName" o,
- leBatches = getBatches o,
- leMethod = getString "method" o,
- lePath = getString "path" o
- }
- _ -> Nothing
- where
- getString k o =
- case KM.lookup k o of
- Just (String s) -> Just s
- _ -> Nothing
-
- getBatches o =
- case KM.lookup "batches" o of
- Just (Array b) ->
- Just
- <| mapMaybe
- ( \case
- Array b0 ->
- Just
- <| mapMaybe
- ( \case
- String s -> Just s
- _ -> Nothing
- )
- (V.toList b0)
- _ -> Nothing
- )
- (V.toList b)
- _ -> Nothing
-
--- | Format a log entry into a user-friendly status message (NO EMOJIS)
-formatLogEntry :: LogEntry -> Maybe Text
-formatLogEntry LogEntry {..} =
- case leMessage of
- Just "executing 1 tools in 1 batch(es)" -> do
- let tools = fromMaybe [] leBatches
- let firstTool = case tools of
- ((t : _) : _) -> t
- _ -> "unknown"
- Just ("THOUGHT: Planning tool execution (" <> firstTool <> ")")
- Just "Tool Bash permitted - action: allow" ->
- Just "TOOL: Bash command executed"
- Just "Processing tool completion for ledger"
- | isJust leToolName ->
- Just ("TOOL: " <> fromMaybe "unknown" leToolName <> " completed")
- Just "ide-fs" | leMethod == Just "readFile" ->
- case lePath of
- Just p -> Just ("READ: " <> p)
- _ -> Nothing
- Just "System prompt build complete (no changes)" ->
- Just "THINKING..."
- Just "System prompt build complete (first build)" ->
- Just "STARTING new task context"
- Just msg
- | leLevel == Just "error" ->
- Just ("ERROR: " <> msg)
- _ -> Nothing
-
-- | Log a scrolling message (appears above status bars)
log :: Text -> IO ()
log msg = do
- -- Clear status bars (5 lines)
- ANSI.hClearLine IO.stderr
- ANSI.hCursorDown IO.stderr 1
- ANSI.hClearLine IO.stderr
- ANSI.hCursorDown IO.stderr 1
- ANSI.hClearLine IO.stderr
- ANSI.hCursorDown IO.stderr 1
+ -- Clear status bars
ANSI.hClearLine IO.stderr
ANSI.hCursorDown IO.stderr 1
ANSI.hClearLine IO.stderr
- ANSI.hCursorUp IO.stderr 4
+ ANSI.hCursorUp IO.stderr 1
-- Print message (scrolls screen)
TIO.hPutStrLn IO.stderr msg
@@ -176,43 +82,90 @@ log msg = do
-- (Since we scrolled, we are now on the line above where the first status line should be)
render
--- | Render the 5 status lines (Vertical Layout)
+-- | Render the two status lines
render :: IO ()
render = do
Status {..} <- readIORef currentStatus
+ -- Line 1: Meta
+ -- [Worker: name] Task: t-123 | Thread: T-abc | Files: 3 | Credits: $0.45 | Time: 05:23
let taskStr = maybe "None" identity statusTask
- threadStr = maybe "None" identity statusThreadId
+ threadStr = maybe "None" identity statusThread
+ meta =
+ "[Worker: "
+ <> statusWorker
+ <> "] Task: "
+ <> taskStr
+ <> " | Thread: "
+ <> threadStr
+ <> " | Files: "
+ <> tshow statusFiles
+ <> " | Credits: $"
+ <> tshow statusCredits
+ <> " | Time: "
+ <> statusTime
- -- Line 1: Worker + Time
ANSI.hSetCursorColumn IO.stderr 0
ANSI.hClearLine IO.stderr
- TIO.hPutStr IO.stderr <| "Worker: " <> statusWorker <> " | Time: " <> statusTime
+ TIO.hPutStr IO.stderr meta
- -- Line 2: Task
+ -- Line 2: Activity
+ -- [14:05:22] > Thinking...
ANSI.hCursorDown IO.stderr 1
ANSI.hSetCursorColumn IO.stderr 0
ANSI.hClearLine IO.stderr
- TIO.hPutStr IO.stderr <| "Task: " <> taskStr
+ TIO.hPutStr IO.stderr ("> " <> statusActivity)
- -- Line 3: Thread
- ANSI.hCursorDown IO.stderr 1
- ANSI.hSetCursorColumn IO.stderr 0
- ANSI.hClearLine IO.stderr
- TIO.hPutStr IO.stderr <| "Thread: " <> threadStr
+ -- Return cursor to line 1
+ ANSI.hCursorUp IO.stderr 1
+ IO.hFlush IO.stderr
- -- Line 4: Credits
- ANSI.hCursorDown IO.stderr 1
- ANSI.hSetCursorColumn IO.stderr 0
- ANSI.hClearLine IO.stderr
- TIO.hPutStr IO.stderr <| "Credits: $" <> str (printf "%.2f" statusCredits :: String)
+-- | Log Entry from JSON
+data LogEntry = LogEntry
+ { leMessage :: Text,
+ leThreadId :: Maybe Text,
+ leCredits :: Maybe Double,
+ leTotalCredits :: Maybe Double,
+ leTimestamp :: Maybe Text
+ }
+ deriving (Show, Eq)
- -- Line 5: Activity
- ANSI.hCursorDown IO.stderr 1
- ANSI.hSetCursorColumn IO.stderr 0
- ANSI.hClearLine IO.stderr
- TIO.hPutStr IO.stderr ("> " <> statusActivity)
+instance Aeson.FromJSON LogEntry where
+ parseJSON =
+ Aeson.withObject "LogEntry" <| \v ->
+ LogEntry
+ </ v
+ .: "message"
+ <*> v
+ .:? "threadId"
+ <*> v
+ .:? "credits"
+ <*> v
+ .:? "totalCredits"
+ <*> v
+ .:? "timestamp"
+
+-- | Parse a log line and update status
+processLogLine :: Text -> IO ()
+processLogLine line = do
+ let bs = BL.fromStrict <| TE.encodeUtf8 line
+ case Aeson.decode bs of
+ Just entry -> update (updateFromEntry entry)
+ Nothing -> pure () -- Ignore invalid JSON
+
+updateFromEntry :: LogEntry -> Status -> Status
+updateFromEntry LogEntry {..} s =
+ s
+ { statusThread = leThreadId <|> statusThread s,
+ statusCredits = fromMaybe (statusCredits s) (leTotalCredits <|> leCredits),
+ statusTime = maybe (statusTime s) formatTime leTimestamp
+ }
- -- Return cursor to Line 1
- ANSI.hCursorUp IO.stderr 4
- IO.hFlush IO.stderr
+formatTime :: Text -> Text
+formatTime ts =
+ -- "2025-11-22T21:24:02.512Z" -> "21:24"
+ case Text.splitOn "T" ts of
+ [_, time] -> case Text.splitOn ":" time of
+ (h : m : _) -> h <> ":" <> m
+ _ -> ts
+ _ -> ts
diff --git a/Omni/Agent/Worker.hs b/Omni/Agent/Worker.hs
index 1cc0b8d..9f9e3bb 100644
--- a/Omni/Agent/Worker.hs
+++ b/Omni/Agent/Worker.hs
@@ -89,7 +89,7 @@ processTask worker task = do
-- Run Amp
AgentLog.updateActivity "Running Amp agent..."
- (exitCode, output) <- runAmp repo task
+ exitCode <- runAmp repo task
case exitCode of
Exit.ExitSuccess -> do
@@ -99,10 +99,8 @@ processTask worker task = do
TaskCore.updateTaskStatus tid TaskCore.Review []
-- Commit changes
- -- We use the agent's output as the extended commit description
- let summary = Text.strip output
- let commitMsg = "feat: implement " <> tid <> "\n\n" <> summary
- Git.commit repo commitMsg
+ -- We should check if there are changes, but 'git add .' is safe.
+ Git.commit repo ("feat: implement " <> tid)
-- Submit for review
AgentLog.updateActivity "Submitting for review..."
@@ -125,7 +123,7 @@ processTask worker task = do
AgentLog.updateActivity "Agent failed, retrying..."
threadDelay (10 * 1000000) -- Sleep 10s
-runAmp :: FilePath -> TaskCore.Task -> IO (Exit.ExitCode, Text)
+runAmp :: FilePath -> TaskCore.Task -> IO Exit.ExitCode
runAmp repo task = do
let prompt =
"You are a Worker Agent.\n"
@@ -137,8 +135,7 @@ runAmp repo task = do
<> "3. Run tests to verify your work (e.g., 'bild --test Omni/Namespace').\n"
<> "4. Fix any errors found during testing.\n"
<> "5. Do NOT update the task status or manage git branches (the system handles that).\n"
- <> "6. Do NOT run 'git commit'. The system will commit your changes automatically.\n"
- <> "7. When finished and tested, exit.\n\n"
+ <> "6. When finished and tested, exit.\n\n"
<> "Context:\n"
<> "- You are working in '"
<> Text.pack repo
@@ -147,38 +144,25 @@ runAmp repo task = do
<> fromMaybe "root" (TaskCore.taskNamespace task)
<> "'.\n"
- Directory.createDirectoryIfMissing True (repo </> "_/llm")
- let logPath = repo </> "_/llm/amp.log"
-
- -- Ensure log file is empty/exists
- IO.writeFile logPath ""
-
- -- Read AGENTS.md
- agentsMd <-
- fmap (fromMaybe "") <| do
- exists <- Directory.doesFileExist (repo </> "AGENTS.md")
- if exists
- then Just </ readFile (repo </> "AGENTS.md")
- else pure Nothing
+ let logFile = repo </> "_/llm/amp.log"
- let fullPrompt =
- prompt
- <> "\n\nREPOSITORY GUIDELINES (AGENTS.md):\n"
- <> agentsMd
+ -- Remove old log file
+ exists <- Directory.doesFileExist logFile
+ when exists (Directory.removeFile logFile)
- -- Monitor log file
- tidLog <- forkIO (monitorLog logPath)
+ Directory.createDirectoryIfMissing True (repo </> "_/llm")
-- Assume amp is in PATH
- let args = ["--log-level", "debug", "--log-file", "_/llm/amp.log", "--dangerously-allow-all", "-x", Text.unpack fullPrompt]
+ let args = ["--log-level", "debug", "--log-file", "_/llm/amp.log", "--dangerously-allow-all", "-x", Text.unpack prompt]
let cp = (Process.proc "amp" args) {Process.cwd = Just repo}
- (exitCode, out, _err) <- Process.readCreateProcessWithExitCode cp ""
+ (_, _, _, ph) <- Process.createProcess cp
- -- Cleanup
- killThread tidLog
+ tid <- forkIO <| monitorLog logFile ph
- pure (exitCode, Text.pack out)
+ exitCode <- Process.waitForProcess ph
+ killThread tid
+ pure exitCode
formatTask :: TaskCore.Task -> Text
formatTask t =
@@ -210,6 +194,37 @@ formatTask t =
where
formatDep dep = " - " <> TaskCore.depId dep <> " [" <> Text.pack (show (TaskCore.depType dep)) <> "]"
+monitorLog :: FilePath -> Process.ProcessHandle -> IO ()
+monitorLog path ph = do
+ waitForFile path
+ IO.withFile path IO.ReadMode <| \h -> do
+ IO.hSetBuffering h IO.LineBuffering
+ go h
+ where
+ go h = do
+ eof <- IO.hIsEOF h
+ if eof
+ then do
+ mExit <- Process.getProcessExitCode ph
+ case mExit of
+ Nothing -> do
+ threadDelay 100000 -- 0.1s
+ go h
+ Just _ -> pure ()
+ else do
+ line <- TIO.hGetLine h
+ AgentLog.processLogLine line
+ go h
+
+waitForFile :: FilePath -> IO ()
+waitForFile path = do
+ exists <- Directory.doesFileExist path
+ if exists
+ then pure ()
+ else do
+ threadDelay 100000
+ waitForFile path
+
findBaseBranch :: FilePath -> TaskCore.Task -> IO Text
findBaseBranch repo task = do
let deps = TaskCore.taskDependencies task
@@ -230,23 +245,3 @@ findBaseBranch repo task = do
case candidates of
(candidate : _) -> pure ("task/" <> TaskCore.depId candidate)
[] -> pure "live"
-
-monitorLog :: FilePath -> IO ()
-monitorLog path = do
- -- Wait for file to exist
- waitForFile path
-
- IO.withFile path IO.ReadMode <| \h -> do
- IO.hSetBuffering h IO.LineBuffering
- forever <| do
- eof <- IO.hIsEOF h
- if eof
- then threadDelay 100000 -- 0.1s
- else do
- line <- TIO.hGetLine h
- AgentLog.processLogLine line
-
-waitForFile :: FilePath -> IO ()
-waitForFile p = do
- e <- Directory.doesFileExist p
- if e then pure () else threadDelay 100000 >> waitForFile p
diff --git a/Omni/Agent/start-worker.sh b/Omni/Agent/start-worker.sh
index 310ca56..457c83c 100755
--- a/Omni/Agent/start-worker.sh
+++ b/Omni/Agent/start-worker.sh
@@ -37,6 +37,12 @@ fi
# Ensure worker has local task and agent binaries
mkdir -p "$WORKER_PATH/_/bin"
+echo "Syncing worker repo..."
+if ! (cd "$WORKER_PATH" && git sync); then
+ echo "Error: Failed to run 'git sync' in worker directory."
+ exit 1
+fi
+
echo "Building 'task' in worker..."
if ! (cd "$WORKER_PATH" && bild Omni/Task.hs); then
echo "Error: Failed to build 'task' in worker directory."
diff --git a/Omni/Ci.hs b/Omni/Ci.hs
new file mode 100644
index 0000000..35abe2b
--- /dev/null
+++ b/Omni/Ci.hs
@@ -0,0 +1,193 @@
+#!/usr/bin/env run.sh
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# LANGUAGE QuasiQuotes #-}
+
+-- | A robust CI program replacing Omni/Ci.sh
+--
+-- : out ci
+module Omni.Ci (main) where
+
+import Alpha
+import qualified Omni.Cli as Cli
+import qualified Omni.Log as Log
+import qualified Omni.Test as Test
+import qualified System.Environment as Environment
+import qualified System.Exit as Exit
+import qualified System.Process as Process
+import qualified Data.Text as Text
+import qualified Data.List as List
+import qualified System.Directory as Dir
+import System.FilePath ((</>))
+
+main :: IO ()
+main = Cli.main <| Cli.Plan help move test pure
+
+help :: Cli.Docopt
+help =
+ [Cli.docopt|
+omni-ci - Continuous Integration
+
+Usage:
+ ci test
+ ci [options]
+
+Options:
+ -h, --help Print this info
+|]
+
+test :: Test.Tree
+test =
+ Test.group
+ "Omni.Ci"
+ [ Test.unit "placeholder test" <| do
+ True @=? True
+ ]
+
+move :: Cli.Arguments -> IO ()
+move _ = do
+ -- 1. Check for dirty worktree
+ status <- readProcess "git" ["status", "-s"] ""
+ unless (null status) <| do
+ Log.fail ["ci", "dirty worktree"]
+ Exit.exitWith (Exit.ExitFailure 1)
+
+ -- 2. Setup environment
+ -- We need to ensure timeout is disabled for CI builds
+ -- Equivalent to: BILD_ARGS="--time 0 ${BILD_ARGS:-""}"
+ currentBildArgs <- Environment.lookupEnv "BILD_ARGS"
+ let bildArgs = "--time 0 " <> fromMaybe "" currentBildArgs
+ Environment.setEnv "BILD_ARGS" bildArgs
+
+ -- 3. Get user info
+ at <- readProcess "date" ["-R"] "" |> fmap chomp
+ user <- readProcess "git" ["config", "--get", "user.name"] "" |> fmap chomp
+ mail <- readProcess "git" ["config", "--get", "user.email"] "" |> fmap chomp
+
+ -- 4. Check existing git notes
+ -- commit=$(git notes --ref=ci show HEAD || true)
+ (exitCode, noteContent, _) <- Process.readProcessWithExitCode "git" ["notes", "--ref=ci", "show", "HEAD"] ""
+
+ let alreadyGood = case exitCode of
+ Exit.ExitSuccess ->
+ let content = Text.pack noteContent
+ in ("Lint-is: good" `Text.isInfixOf` content) && ("Test-is: good" `Text.isInfixOf` content)
+ _ -> False
+
+ when alreadyGood <| do
+ Log.pass ["ci", "already verified"]
+ Exit.exitSuccess
+
+ -- 5. Run Lint
+ coderoot <- getCoderoot
+ let runlint = coderoot </> "_/bin/lint"
+
+ lintExists <- Dir.doesFileExist runlint
+ unless lintExists <| do
+ Log.info ["ci", "building lint"]
+ callProcess "bild" [coderoot </> "Omni/Lint.hs"]
+
+ Log.info ["ci", "running lint"]
+ -- if "$runlint" "${CODEROOT:?}"/**/*
+ -- We need to expand **/* which shell does.
+ -- Since we are in Haskell, we can just pass "." or call git ls-files or similar.
+ -- Omni/Ci.sh used "${CODEROOT:?}"/**/* which relies on bash globbing.
+ -- Omni/Lint.hs recursively checks if passed directory or uses git diff if no args.
+ -- But Omni/Ci.sh passes **/*.
+ -- Let's try passing the root directory or just run it without args?
+ -- Omni/Lint.hs says:
+ -- "case Cli.getAllArgs args (Cli.argument "file") of [] -> changedFiles ..."
+ -- So if we pass nothing, it only checks changed files.
+ -- The CI script explicitly passed everything.
+ -- We can replicate "everything" by passing the coderoot, assuming Lint handles directories recursively?
+ -- Omni/Lint.hs: "traverse Directory.makeAbsolute /> map (Namespace.fromPath root) ... filter (not <. Namespace.isCab)"
+ -- It seems it expects files.
+ -- We can use `git ls-files` to get all files.
+ allFiles <- readProcess "git" ["ls-files"] ""
+ /> lines
+ /> map Text.unpack
+ /> filter (not . null)
+
+ -- We can't pass all files as arguments if there are too many (ARG_MAX).
+ -- But wait, Omni/Lint.hs takes arguments.
+ -- If we want to check everything, maybe we should implement a "check all" mode in Lint or pass chunks.
+ -- However, looking at Omni/Ci.sh: `"$runlint" "${CODEROOT:?}"/**/*`
+ -- This globbing is handled by the shell. It might be huge.
+ -- If I run `lint` with `.` it might work if Lint supports directories.
+ -- Omni/Lint.hs: "files |> ... filterM Directory.doesFileExist"
+ -- It seems it filters for files.
+ -- If I pass a directory, `doesFileExist` will return False.
+ -- So I must pass files.
+
+ -- Let's pass all files from git ls-files.
+ -- But we must be careful about ARG_MAX.
+ -- For now, let's try passing them. If it fails, we might need to batch.
+
+ lintResult <- do
+ -- We run lint on all files.
+ -- Note: calling callProcess with huge list might fail.
+ -- Let's see if we can avoid passing all files if Lint supports it.
+ -- Omni/Lint.hs doesn't seem to support directory recursion on its own if passed a dir,
+ -- it treats args as file paths.
+
+ -- We will try to run it.
+ (exitCodeLint, _, _) <- Process.readProcessWithExitCode runlint allFiles ""
+ pure $ case exitCodeLint of
+ Exit.ExitSuccess -> "good"
+ _ -> "fail"
+
+ -- 6. Run Tests
+ -- if bild "${BILD_ARGS:-""}" --test "${CODEROOT:?}"/**/*
+ Log.info ["ci", "running tests"]
+
+ testResult <- do
+ -- similarly, bild takes targets.
+ -- bild "${CODEROOT:?}"/**/*
+ -- We can pass namespaces.
+ -- Let's try passing all files again.
+ -- bild handles namespaces.
+ (exitCodeTest, _, _) <- Process.readProcessWithExitCode "bild" ("--test" : allFiles) ""
+ pure $ case exitCodeTest of
+ Exit.ExitSuccess -> "good"
+ _ -> "fail"
+
+ -- 7. Create Note
+ let note = Text.unlines
+ [ "Lint-is: " <> lintResult
+ , "Test-is: " <> testResult
+ , "Test-by: " <> user <> " <" <> mail <> ">"
+ , "Test-at: " <> at
+ ]
+
+ -- 8. Append Note
+ callProcess "git" ["notes", "--ref=ci", "append", "-m", Text.unpack note]
+
+ -- 9. Exit
+ if lintResult == "good" && testResult == "good"
+ then Exit.exitSuccess
+ else do
+ Log.fail ["ci", "verification failed"]
+ Exit.exitWith (Exit.ExitFailure 1)
+
+
+-- Helpers
+
+readProcess :: FilePath -> [String] -> String -> IO Text
+readProcess cmd args input = do
+ out <- Process.readProcess cmd args input
+ pure (Text.pack out)
+
+callProcess :: FilePath -> [String] -> IO ()
+callProcess cmd args = do
+ Process.callProcess cmd args
+
+getCoderoot :: IO FilePath
+getCoderoot = do
+ mEnvRoot <- Environment.lookupEnv "CODEROOT"
+ cwd <- Dir.getCurrentDirectory
+ case mEnvRoot of
+ Just envRoot -> pure envRoot
+ Nothing -> panic "CODEROOT not set" -- Simplified for now
+
diff --git a/Omni/Ci.sh b/Omni/Ci.sh
deleted file mode 100755
index a749b7a..0000000
--- a/Omni/Ci.sh
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/usr/bin/env bash
-#
-# A simple ci that saves its results in a git note, formatted according to
-# RFC-2822, more or less.
-#
-# To run this manually, exec the script. It will by default run the tests for
-# HEAD, whatever you currently have checked out.
-#
-# It would be cool to use a zero-knowledge proof mechanism here to prove that
-# so-and-so ran the tests, but I'll have to research how to do that.
-#
-# ensure we don't exit on bild failure, only on CI script error
- set +e
- set -u
-##
- [[ -n $(git status -s) ]] && { echo fail: dirty worktree; exit 1; }
-##
-## disable timeout for ci builds
- BILD_ARGS="--time 0 ${BILD_ARGS:-""}"
-##
- at=$(date -R)
- user=$(git config --get user.name)
- mail=$(git config --get user.email)
-##
- commit=$(git notes --ref=ci show HEAD || true)
- if [[ -n "$commit" ]]
- then
- if grep -q "Lint-is: good" <<< "$commit"
- then
- exit 0
- fi
- if grep -q "Test-is: good" <<< "$commit"
- then
- exit 0
- fi
- fi
-##
- runlint="$CODEROOT"/_/bin/lint
- [[ ! -f "$runlint" ]] && bild "${BILD_ARGS:-""}" "${CODEROOT:?}"/Omni/Lint.hs
- if "$runlint" "${CODEROOT:?}"/**/*
- then
- lint_result="good"
- else
- lint_result="fail"
- fi
-##
- if bild "${BILD_ARGS:-""}" --test "${CODEROOT:?}"/**/*
- then
- test_result="good"
- else
- test_result="fail"
- fi
-##
- read -r -d '' note <<EOF
-Lint-is: $lint_result
-Test-is: $test_result
-Test-by: $user <$mail>
-Test-at: $at
-EOF
-##
- git notes --ref=ci append -m "$note"
-##
-# exit 1 if failure
- [[ ! "$lint_result" == "fail" && ! "$test_result" == "fail" ]]
-##
diff --git a/Omni/Task.hs b/Omni/Task.hs
index e4294dc..82449db 100644
--- a/Omni/Task.hs
+++ b/Omni/Task.hs
@@ -4,6 +4,7 @@
{-# LANGUAGE NoImplicitPrelude #-}
-- : out task
+-- : modified by benign worker
module Omni.Task where
import Alpha
@@ -79,8 +80,8 @@ Options:
--title=<title> Task title
--type=<type> Task type: epic or task
--parent=<id> Parent epic ID
- --priority=<p> Priority: 0-4 (0=critical, 4=backlog)
- --status=<status> Task status (open, in-progress, review, done)
+ --priority=<p> Priority: 0-4 (0=critical, 4=backlog, default: 2)
+ --status=<status> Filter by status: open, in-progress, review, approved, 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
@@ -95,7 +96,7 @@ Options:
Arguments:
<title> Task title
<id> Task ID
- <status> Task status (open, in-progress, review, done)
+ <status> Task status (open, in-progress, review, approved, done)
<file> JSONL file to import
|]
@@ -255,8 +256,9 @@ move args
Just "open" -> pure <| Just Open
Just "in-progress" -> pure <| Just InProgress
Just "review" -> pure <| Just Review
+ Just "approved" -> pure <| Just Approved
Just "done" -> pure <| Just Done
- Just other -> panic <| "Invalid status: " <> T.pack other <> ". Use: open, in-progress, review, or done"
+ Just other -> panic <| "Invalid status: " <> T.pack other <> ". Use: open, in-progress, review, approved, or done"
maybeNamespace <- case Cli.getArg args (Cli.longOption "namespace") of
Nothing -> pure Nothing
Just ns -> do
@@ -306,8 +308,9 @@ move args
"open" -> Open
"in-progress" -> InProgress
"review" -> Review
+ "approved" -> Approved
"done" -> Done
- _ -> panic "Invalid status. Use: open, in-progress, review, or done"
+ _ -> panic "Invalid status. Use: open, in-progress, review, approved, or done"
updateTaskStatus tid newStatus deps
if isJsonMode args
@@ -669,6 +672,13 @@ cliTests =
Right args -> do
args `Cli.has` Cli.command "list" Test.@?= True
Cli.getArg args (Cli.longOption "status") Test.@?= Just "open",
+ Test.unit "list with --status=approved filter" <| do
+ let result = Docopt.parseArgs help ["list", "--status=approved"]
+ case result of
+ Left err -> Test.assertFailure <| "Failed to parse 'list --status=approved': " <> show err
+ Right args -> do
+ args `Cli.has` Cli.command "list" Test.@?= True
+ Cli.getArg args (Cli.longOption "status") Test.@?= Just "approved",
Test.unit "ready command" <| do
let result = Docopt.parseArgs help ["ready"]
case result of
@@ -689,6 +699,14 @@ cliTests =
args `Cli.has` Cli.command "update" Test.@?= True
Cli.getArg args (Cli.argument "id") Test.@?= Just "t-abc123"
Cli.getArg args (Cli.argument "status") Test.@?= Just "done",
+ Test.unit "update command with approved" <| do
+ let result = Docopt.parseArgs help ["update", "t-abc123", "approved"]
+ case result of
+ Left err -> Test.assertFailure <| "Failed to parse 'update ... approved': " <> show err
+ Right args -> do
+ args `Cli.has` Cli.command "update" Test.@?= True
+ Cli.getArg args (Cli.argument "id") Test.@?= Just "t-abc123"
+ Cli.getArg args (Cli.argument "status") Test.@?= Just "approved",
Test.unit "update with --json flag" <| do
let result = Docopt.parseArgs help ["update", "t-abc123", "done", "--json"]
case result of
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
index a9f6743..2f2cccb 100644
--- a/Omni/Task/Core.hs
+++ b/Omni/Task/Core.hs
@@ -42,7 +42,7 @@ data Task = Task
data TaskType = Epic | WorkTask
deriving (Show, Eq, Generic)
-data Status = Open | InProgress | Review | Done
+data Status = Open | InProgress | Review | Approved | Done
deriving (Show, Eq, Generic)
-- Priority levels (matching beads convention)
@@ -579,6 +579,7 @@ showTaskTree maybeId = do
Open -> "[ ]"
InProgress -> "[~]"
Review -> "[?]"
+ Approved -> "[+]"
Done -> "[✓]"
coloredStatusStr = case taskType task of
@@ -587,6 +588,7 @@ showTaskTree maybeId = do
Open -> bold statusStr
InProgress -> yellow statusStr
Review -> magenta statusStr
+ Approved -> green statusStr
Done -> green statusStr
nsStr = case taskNamespace task of
@@ -646,6 +648,7 @@ printTask t = do
Open -> bold s
InProgress -> yellow s
Review -> magenta s
+ Approved -> green s
Done -> green s
coloredTitle = if taskType t == Epic then bold (taskTitle t) else taskTitle t
@@ -756,6 +759,7 @@ data TaskStats = TaskStats
openTasks :: Int,
inProgressTasks :: Int,
reviewTasks :: Int,
+ approvedTasks :: Int,
doneTasks :: Int,
totalEpics :: Int,
readyTasks :: Int,
@@ -791,6 +795,7 @@ getTaskStats maybeEpicId = do
open = length <| filter (\t -> taskStatus t == Open) tasks
inProg = length <| filter (\t -> taskStatus t == InProgress) tasks
review = length <| filter (\t -> taskStatus t == Review) tasks
+ approved = length <| filter (\t -> taskStatus t == Approved) tasks
done = length <| filter (\t -> taskStatus t == Done) tasks
epics = length <| filter (\t -> taskType t == Epic) tasks
readyCount' = readyCount
@@ -813,6 +818,7 @@ getTaskStats maybeEpicId = do
openTasks = open,
inProgressTasks = inProg,
reviewTasks = review,
+ approvedTasks = approved,
doneTasks = done,
totalEpics = epics,
readyTasks = readyCount',
@@ -840,6 +846,7 @@ showTaskStats maybeEpicId = do
putText <| " Open: " <> T.pack (show (openTasks stats))
putText <| " In Progress: " <> T.pack (show (inProgressTasks stats))
putText <| " Review: " <> T.pack (show (reviewTasks stats))
+ putText <| " Approved: " <> T.pack (show (approvedTasks stats))
putText <| " Done: " <> T.pack (show (doneTasks stats))
putText ""
putText <| "Epics: " <> T.pack (show (totalEpics stats))