summaryrefslogtreecommitdiff
path: root/Omni/Task.hs
blob: c6e68ac995f0459e934a9064fee265a99963a0e2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoImplicitPrelude #-}

-- : dep sqlite-simple
module Omni.Task where

import Alpha
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy.Char8 as BLC
import qualified Data.Text as T
import qualified Omni.Cli as Cli
import qualified Omni.Namespace as Namespace
import Omni.Task.Core
import qualified Omni.Task.MigrationTest as MigrationTest
import qualified Omni.Task.RaceTest as RaceTest
import qualified Omni.Test as Test
import qualified System.Console.Docopt as Docopt
import System.Directory (createDirectoryIfMissing, doesFileExist, removeFile)
import System.Environment (setEnv)
import qualified Test.Tasty as Tasty
import Web.HttpApiData (parseQueryParam)
import Prelude (read)

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 [--quiet]
  task create <title> [options]
  task edit <id> [options]
  task delete <id> [--json]
  task comment <id> <message> [--json]
  task list [options]
  task ready [--json]
  task show <id> [--json]
  task update <id> <status> [options] [--verified]
  task deps <id> [--json]
  task tree [<id>] [--json]
  task progress <id> [--json]
  task stats [--epic=<id>] [--json]
  task export [-o <file>]
  task import -i <file>
  task test
  task (-h | --help)

Commands:
  init                          Initialize task database
  create                        Create a new task or epic
  edit                          Edit an existing task
  delete                        Delete a task
  comment                       Add a comment to a task
  list                          List all tasks
  ready                         Show ready tasks (not blocked)
  show                          Show detailed task information
  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 tasks to JSONL
  import                        Import tasks from JSONL file
  test                          Run tests

Options:
  -h --help                     Show this help
  --title=<title>               Task title
  --type=<type>                 Task type: epic, task, or human (default: task)
  --parent=<id>                 Parent epic ID
  --priority=<p>                Priority: 0-4 (0=critical, 4=backlog, default: 2)
  --complexity=<c>              Complexity: 1-5 for model selection (1=trivial, 5=expert)
  --status=<status>             Filter by status: draft, 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
  --discovered-from=<id>        Shortcut for --deps=<id> --dep-type=discovered-from
  --namespace=<ns>              Optional namespace (e.g., Omni/Task, Biz/Cloud)
  --description=<desc>          Task description
  --db=<file>                   Path to SQLite database (overrides TASK_DB_PATH)
  --flush                       Force immediate export
  --json                        Output in JSON format (for agent use)
  --quiet                       Non-interactive mode (for agents)
  --verified                    Mark task as verified (code compiles, tests pass, feature works)
  -i <file>                     Input file for import
  -o <file>                     Output file for export

Arguments:
  <title>                       Task title
  <id>                          Task ID
  <status>                      Task status (draft, open, in-progress, review, approved, done)
  <message>                     Comment message
  <file>                        JSONL file to import
|]

-- Helper to check if JSON output is requested
isJsonMode :: Cli.Arguments -> Bool
isJsonMode args = args `Cli.has` Cli.longOption "json"

-- Helper to output JSON
outputJson :: (Aeson.ToJSON a) => a -> IO ()
outputJson val = BLC.putStrLn <| Aeson.encode val

-- Helper for success message in JSON
outputSuccess :: Text -> IO ()
outputSuccess msg = outputJson <| Aeson.object ["success" Aeson..= True, "message" Aeson..= msg]

move :: Cli.Arguments -> IO ()
move args = do
  -- Handle --db flag globally
  for_
    (Cli.getArg args (Cli.longOption "db"))
    (setEnv "TASK_DB_PATH")

  move' args

move' :: Cli.Arguments -> IO ()
move' args
  | args `Cli.has` Cli.command "init" = do
      let quiet = args `Cli.has` Cli.longOption "quiet"
      initTaskDb
      unless quiet <| putText "Task database initialized. Use 'task create' to add tasks."
  | args `Cli.has` Cli.command "create" = do
      title <- getArgText args "title"
      taskType <- case Cli.getArg args (Cli.longOption "type") of
        Nothing -> pure WorkTask
        Just "epic" -> pure Epic
        Just "task" -> pure WorkTask
        Just "human" -> pure HumanTask
        Just other -> panic <| "Invalid task type: " <> T.pack other <> ". Use: epic, task, or human"
      parent <- case Cli.getArg args (Cli.longOption "parent") of
        Nothing -> pure Nothing
        Just p -> pure <| Just (T.pack p)

      -- Handle --discovered-from as shortcut
      (depIds, depType) <- case Cli.getArg args (Cli.longOption "discovered-from") of
        Just discoveredId -> pure ([T.pack discoveredId], DiscoveredFrom)
        Nothing -> do
          -- Parse regular --deps and --dep-type
          ids <- case Cli.getArg args (Cli.longOption "deps") of
            Nothing -> pure []
            Just depStr -> pure <| T.splitOn "," (T.pack depStr)
          dtype <- case Cli.getArg args (Cli.longOption "dep-type") of
            Nothing -> pure Blocks
            Just "blocks" -> pure Blocks
            Just "discovered-from" -> pure DiscoveredFrom
            Just "parent-child" -> pure ParentChild
            Just "related" -> pure Related
            Just other -> panic <| "Invalid dependency type: " <> T.pack other <> ". Use: blocks, discovered-from, parent-child, or related"
          pure (ids, dtype)

      let deps = map (\did -> Dependency {depId = did, depType = depType}) depIds

      -- Parse priority (default to P2 = medium)
      priority <- case Cli.getArg args (Cli.longOption "priority") of
        Nothing -> pure P2
        Just "0" -> pure P0
        Just "1" -> pure P1
        Just "2" -> pure P2
        Just "3" -> pure P3
        Just "4" -> pure P4
        Just other -> panic <| "Invalid priority: " <> T.pack other <> ". Use: 0-4"

      -- Parse complexity (1-5 scale)
      complexity <- case Cli.getArg args (Cli.longOption "complexity") of
        Nothing -> pure Nothing
        Just c -> case readMaybe c of
          Just n | n >= 1 && n <= 5 -> pure (Just n)
          _ -> panic <| "Invalid complexity: " <> T.pack c <> ". Use: 1-5"

      namespace <- case Cli.getArg args (Cli.longOption "namespace") of
        Nothing -> pure Nothing
        Just ns -> do
          -- Validate it's a proper namespace by parsing it
          let validNs = Namespace.fromHaskellModule ns
              nsPath = T.pack <| Namespace.toPath validNs
          pure <| Just nsPath

      description <- case Cli.getArg args (Cli.longOption "description") of
        Nothing -> panic "--description is required for task create"
        Just d -> pure (T.pack d)

      createdTask <- createTask title taskType parent namespace priority complexity deps description
      if isJsonMode args
        then outputJson createdTask
        else putStrLn <| "Created task: " <> T.unpack (taskId createdTask)
  | args `Cli.has` Cli.command "edit" = do
      tid <- getArgText args "id"

      -- Parse optional edits
      maybeTitle <- pure <| Cli.getArg args (Cli.longOption "title")
      maybeType <- case Cli.getArg args (Cli.longOption "type") of
        Nothing -> pure Nothing
        Just "epic" -> pure <| Just Epic
        Just "task" -> pure <| Just WorkTask
        Just other -> panic <| "Invalid task type: " <> T.pack other <> ". Use: epic or task"
      maybeParent <- pure <| fmap T.pack (Cli.getArg args (Cli.longOption "parent"))
      maybePriority <- case Cli.getArg args (Cli.longOption "priority") of
        Nothing -> pure Nothing
        Just "0" -> pure <| Just P0
        Just "1" -> pure <| Just P1
        Just "2" -> pure <| Just P2
        Just "3" -> pure <| Just P3
        Just "4" -> pure <| Just P4
        Just other -> panic <| "Invalid priority: " <> T.pack other <> ". Use: 0-4"
      maybeComplexity <- case Cli.getArg args (Cli.longOption "complexity") of
        Nothing -> pure Nothing
        Just c -> case readMaybe c of
          Just n | n >= 1 && n <= 5 -> pure (Just (Just n))
          _ -> panic <| "Invalid complexity: " <> T.pack c <> ". Use: 1-5"
      maybeStatus <- case Cli.getArg args (Cli.longOption "status") of
        Nothing -> pure Nothing
        Just "draft" -> pure <| Just Draft
        Just "open" -> pure <| Just Open
        Just "in-progress" -> pure <| Just InProgress
        Just "review" -> pure <| Just Review
        Just "done" -> pure <| Just Done
        Just other -> panic <| "Invalid status: " <> T.pack other <> ". Use: draft, open, in-progress, review, or done"
      maybeNamespace <- case Cli.getArg args (Cli.longOption "namespace") of
        Nothing -> pure Nothing
        Just ns -> do
          let validNs = Namespace.fromHaskellModule ns
              nsPath = T.pack <| Namespace.toPath validNs
          pure <| Just nsPath
      maybeDesc <- pure <| fmap T.pack (Cli.getArg args (Cli.longOption "description"))

      maybeDeps <- case Cli.getArg args (Cli.longOption "discovered-from") of
        Just discoveredId -> pure <| Just [Dependency {depId = T.pack discoveredId, depType = DiscoveredFrom}]
        Nothing -> case Cli.getArg args (Cli.longOption "deps") of
          Nothing -> pure Nothing
          Just depStr -> do
            let ids = T.splitOn "," (T.pack depStr)
            dtype <- case Cli.getArg args (Cli.longOption "dep-type") of
              Nothing -> pure Blocks
              Just "blocks" -> pure Blocks
              Just "discovered-from" -> pure DiscoveredFrom
              Just "parent-child" -> pure ParentChild
              Just "related" -> pure Related
              Just other -> panic <| "Invalid dependency type: " <> T.pack other
            pure <| Just (map (\did -> Dependency {depId = did, depType = dtype}) ids)

      let modifyFn task =
            task
              { taskTitle = maybe (taskTitle task) T.pack maybeTitle,
                taskType = fromMaybe (taskType task) maybeType,
                taskParent = case maybeParent of Nothing -> taskParent task; Just p -> Just p,
                taskNamespace = case maybeNamespace of Nothing -> taskNamespace task; Just ns -> Just ns,
                taskStatus = fromMaybe (taskStatus task) maybeStatus,
                taskPriority = fromMaybe (taskPriority task) maybePriority,
                taskComplexity = fromMaybe (taskComplexity task) maybeComplexity,
                taskDescription = fromMaybe (taskDescription task) maybeDesc,
                taskDependencies = fromMaybe (taskDependencies task) maybeDeps
              }

      updatedTask <- editTask tid modifyFn
      if isJsonMode args
        then outputJson updatedTask
        else putStrLn <| "Updated task: " <> T.unpack (taskId updatedTask)
  | args `Cli.has` Cli.command "delete" = do
      tid <- getArgText args "id"
      deleteTask tid
      if isJsonMode args
        then outputSuccess ("Deleted task " <> tid)
        else putStrLn <| "Deleted task: " <> T.unpack tid
  | args `Cli.has` Cli.command "comment" = do
      tid <- getArgText args "id"
      message <- getArgText args "message"
      updatedTask <- addComment tid message
      if isJsonMode args
        then outputJson updatedTask
        else putStrLn <| "Added comment to task: " <> T.unpack tid
  | args `Cli.has` Cli.command "list" = do
      maybeType <- case Cli.getArg args (Cli.longOption "type") of
        Nothing -> pure Nothing
        Just "epic" -> pure <| Just Epic
        Just "task" -> pure <| Just WorkTask
        Just "human" -> pure <| Just HumanTask
        Just other -> panic <| "Invalid task type: " <> T.pack other
      maybeParent <- case Cli.getArg args (Cli.longOption "parent") of
        Nothing -> pure Nothing
        Just p -> pure <| Just (T.pack p)
      maybeStatus <- case Cli.getArg args (Cli.longOption "status") of
        Nothing -> pure Nothing
        Just "draft" -> pure <| Just Draft
        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: draft, open, in-progress, review, approved, or done"
      maybeNamespace <- case Cli.getArg args (Cli.longOption "namespace") of
        Nothing -> pure Nothing
        Just ns -> do
          let validNs = Namespace.fromHaskellModule ns
              nsPath = T.pack <| Namespace.toPath validNs
          pure <| Just nsPath
      tasks <- listTasks maybeType maybeParent maybeStatus maybeNamespace
      if isJsonMode args
        then outputJson tasks
        else traverse_ printTask tasks
  | args `Cli.has` Cli.command "ready" = do
      tasks <- getReadyTasks
      if isJsonMode args
        then outputJson tasks
        else do
          putText "Ready tasks:"
          traverse_ printTask tasks
  | args `Cli.has` Cli.command "show" = do
      tid <- getArgText args "id"
      tasks <- loadTasks
      case findTask tid tasks of
        Nothing -> putText "Task not found"
        Just task ->
          if isJsonMode args
            then outputJson task
            else showTaskDetailed task
  | args `Cli.has` Cli.command "update" = do
      tid <- getArgText args "id"
      statusStr <- getArgText args "status"
      let isVerified = args `Cli.has` Cli.longOption "verified"

      -- Handle update dependencies
      deps <- do
        -- Parse --deps and --dep-type
        ids <- case Cli.getArg args (Cli.longOption "deps") of
          Nothing -> pure []
          Just depStr -> pure <| T.splitOn "," (T.pack depStr)
        dtype <- case Cli.getArg args (Cli.longOption "dep-type") of
          Nothing -> pure Blocks
          Just "blocks" -> pure Blocks
          Just "discovered-from" -> pure DiscoveredFrom
          Just "parent-child" -> pure ParentChild
          Just "related" -> pure Related
          Just other -> panic <| "Invalid dependency type: " <> T.pack other <> ". Use: blocks, discovered-from, parent-child, or related"
        pure (map (\d -> Dependency {depId = d, depType = dtype}) ids)

      let newStatus = case statusStr of
            "draft" -> Draft
            "open" -> Open
            "in-progress" -> InProgress
            "review" -> Review
            "approved" -> Approved
            "done" -> Done
            _ -> panic "Invalid status. Use: draft, open, in-progress, review, approved, or done"

      -- Show verification checklist warning when marking Done without --verified
      when (newStatus == Done && not isVerified && not (isJsonMode args)) <| do
        putText ""
        putText "⚠️  VERIFICATION CHECKLIST (use --verified to skip):"
        putText "   [ ] Code compiles (bild succeeds)"
        putText "   [ ] Tests pass (bild --test)"
        putText "   [ ] Feature works in production (manual verification)"
        putText ""

      updateTaskStatus tid newStatus deps

      -- Record verification in activity log if verified
      when (newStatus == Done && isVerified)
        <| logActivity tid Completed (Just "{\"verified\":true}")

      if isJsonMode args
        then
          if newStatus == Done && isVerified
            then outputJson <| Aeson.object ["success" Aeson..= True, "message" Aeson..= ("Updated task " <> tid), "verified" Aeson..= True]
            else outputSuccess <| "Updated task " <> tid
        else
          if newStatus == Done && isVerified
            then putStrLn <| "Updated task " <> T.unpack tid <> " (verified ✓)"
            else putStrLn <| "Updated task " <> T.unpack tid
  | args `Cli.has` Cli.command "deps" = do
      tid <- getArgText args "id"
      if isJsonMode args
        then do
          deps <- getDependencyTree tid
          outputJson deps
        else showDependencyTree tid
  | args `Cli.has` Cli.command "tree" = do
      maybeId <- case Cli.getArg args (Cli.argument "id") of
        Nothing -> pure Nothing
        Just idStr -> pure <| Just (T.pack idStr)
      if isJsonMode args
        then do
          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 maybeEpic
          outputJson stats
        else showTaskStats maybeEpic
  | args `Cli.has` Cli.command "export" = do
      file <- case Cli.getArg args (Cli.shortOption 'o') of
        Nothing -> pure Nothing
        Just f -> pure (Just f)
      exportTasks file
      case file of
        Just f -> putText <| "Exported tasks to " <> T.pack f
        Nothing -> pure ()
  | args `Cli.has` Cli.command "import" = do
      -- Note: -i <file> means the value is stored in option 'i', not argument "file"
      file <- case Cli.getArg args (Cli.shortOption 'i') of
        Nothing -> panic "import requires -i <file>"
        Just f -> pure (T.pack f)
      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,
      cliTests,
      Tasty.after Tasty.AllSucceed "Unit tests" RaceTest.test,
      Tasty.after Tasty.AllSucceed "Unit tests" MigrationTest.test
    ]

unitTests :: Test.Tree
unitTests =
  Test.group
    "Unit tests"
    [ Test.unit "setup test database" <| do
        -- Set up test mode for all tests (uses _/tmp/tasks-test.db)
        setEnv "TASK_TEST_MODE" "1"

        -- Clean up test database before all tests
        let testFile = "_/tmp/tasks-test.db"
        createDirectoryIfMissing True "_/tmp"
        exists <- doesFileExist testFile
        when exists <| removeFile testFile
        initTaskDb
        True Test.@?= True,
      Test.unit "can create task" <| do
        task <- createTask "Test task" WorkTask Nothing Nothing P2 Nothing [] "Test description"
        taskTitle task Test.@?= "Test task"
        taskType task Test.@?= WorkTask
        taskStatus task Test.@?= Open
        taskPriority task Test.@?= P2
        null (taskDependencies task) Test.@?= True,
      Test.unit "can create human task" <| do
        task <- createTask "Human Task" HumanTask Nothing Nothing P2 Nothing [] "Human task description"
        taskType task Test.@?= HumanTask,
      Test.unit "ready tasks exclude human tasks" <| do
        task <- createTask "Human Task" HumanTask Nothing Nothing P2 Nothing [] "Human task"
        ready <- getReadyTasks
        (taskId task `notElem` map taskId ready) Test.@?= True,
      Test.unit "ready tasks exclude draft tasks" <| do
        task <- createTask "Draft Task" WorkTask Nothing Nothing P2 Nothing [] "Draft description"
        updateTaskStatus (taskId task) Draft []
        ready <- getReadyTasks
        (taskId task `notElem` map taskId ready) Test.@?= True,
      Test.unit "can create task with description" <| do
        task <- createTask "Test task" WorkTask Nothing Nothing P2 Nothing [] "My description"
        taskDescription task Test.@?= "My description",
      Test.unit "can create task with complexity" <| do
        task <- createTask "Complex task" WorkTask Nothing Nothing P2 (Just 4) [] "High complexity task"
        taskComplexity task Test.@?= Just 4,
      Test.unit "complexity is persisted" <| do
        task <- createTask "Persisted complexity" WorkTask Nothing Nothing P2 (Just 3) [] "Medium complexity"
        tasks <- loadTasks
        case findTask (taskId task) tasks of
          Nothing -> Test.assertFailure "Could not reload task"
          Just reloaded -> taskComplexity reloaded Test.@?= Just 3,
      Test.unit "can list tasks" <| do
        _ <- createTask "Test task for list" WorkTask Nothing Nothing P2 Nothing [] "List test"
        tasks <- listTasks Nothing Nothing Nothing Nothing
        not (null tasks) Test.@?= True,
      Test.unit "ready tasks exclude blocked ones" <| do
        task1 <- createTask "First task" WorkTask Nothing Nothing P2 Nothing [] "First description"
        let blockingDep = Dependency {depId = taskId task1, depType = Blocks}
        task2 <- createTask "Blocked task" WorkTask Nothing Nothing P2 Nothing [blockingDep] "Blocked description"
        ready <- getReadyTasks
        (taskId task1 `elem` map taskId ready) Test.@?= True
        (taskId task2 `notElem` map taskId ready) Test.@?= True,
      Test.unit "discovered-from dependencies don't block" <| do
        task1 <- createTask "Original task" WorkTask Nothing Nothing P2 Nothing [] "Original"
        let discDep = Dependency {depId = taskId task1, depType = DiscoveredFrom}
        task2 <- createTask "Discovered work" WorkTask Nothing Nothing P2 Nothing [discDep] "Discovered"
        ready <- getReadyTasks
        -- Both should be ready since DiscoveredFrom doesn't block
        (taskId task1 `elem` map taskId ready) Test.@?= True
        (taskId task2 `elem` map taskId ready) Test.@?= True,
      Test.unit "related dependencies don't block" <| do
        task1 <- createTask "Task A" WorkTask Nothing Nothing P2 Nothing [] "Task A description"
        let relDep = Dependency {depId = taskId task1, depType = Related}
        task2 <- createTask "Task B" WorkTask Nothing Nothing P2 Nothing [relDep] "Task B description"
        ready <- getReadyTasks
        -- Both should be ready since Related doesn't block
        (taskId task1 `elem` map taskId ready) Test.@?= True
        (taskId task2 `elem` map taskId ready) Test.@?= True,
      Test.unit "ready tasks exclude epics" <| do
        epic <- createTask "Epic task" Epic Nothing Nothing P2 Nothing [] "Epic description"
        ready <- getReadyTasks
        (taskId epic `notElem` map taskId ready) Test.@?= True,
      Test.unit "ready tasks exclude tasks needing intervention (retry >= 3)" <| do
        task <- createTask "Failing task" WorkTask Nothing Nothing P2 Nothing [] "Failing description"
        ready1 <- getReadyTasks
        (taskId task `elem` map taskId ready1) Test.@?= True
        setRetryContext
          RetryContext
            { retryTaskId = taskId task,
              retryOriginalCommit = "abc123",
              retryConflictFiles = [],
              retryAttempt = 3,
              retryReason = "test_failure",
              retryNotes = Nothing
            }
        ready2 <- getReadyTasks
        (taskId task `notElem` map taskId ready2) Test.@?= True,
      Test.unit "child task gets sequential ID" <| do
        parent <- createTask "Parent" Epic Nothing Nothing P2 Nothing [] "Parent epic"
        child1 <- createTask "Child 1" WorkTask (Just (taskId parent)) Nothing P2 Nothing [] "Child 1 description"
        child2 <- createTask "Child 2" WorkTask (Just (taskId parent)) Nothing P2 Nothing [] "Child 2 description"
        taskId child1 Test.@?= taskId parent <> ".1"
        taskId child2 Test.@?= taskId parent <> ".2",
      Test.unit "grandchild task gets sequential ID" <| do
        parent <- createTask "Grandparent" Epic Nothing Nothing P2 Nothing [] "Grandparent epic"
        child <- createTask "Parent" Epic (Just (taskId parent)) Nothing P2 Nothing [] "Parent epic"
        grandchild <- createTask "Grandchild" WorkTask (Just (taskId child)) Nothing P2 Nothing [] "Grandchild task"
        taskId grandchild Test.@?= taskId parent <> ".1.1",
      Test.unit "siblings of grandchild task get sequential ID" <| do
        parent <- createTask "Grandparent" Epic Nothing Nothing P2 Nothing [] "Grandparent"
        child <- createTask "Parent" Epic (Just (taskId parent)) Nothing P2 Nothing [] "Parent"
        grandchild1 <- createTask "Grandchild 1" WorkTask (Just (taskId child)) Nothing P2 Nothing [] "Grandchild 1"
        grandchild2 <- createTask "Grandchild 2" WorkTask (Just (taskId child)) Nothing P2 Nothing [] "Grandchild 2"
        taskId grandchild1 Test.@?= taskId parent <> ".1.1"
        taskId grandchild2 Test.@?= taskId parent <> ".1.2",
      Test.unit "child ID generation skips gaps" <| do
        parent <- createTask "Parent with gaps" Epic Nothing Nothing P2 Nothing [] "Parent with gaps"
        child1 <- createTask "Child 1" WorkTask (Just (taskId parent)) Nothing P2 Nothing [] "Child 1"
        -- 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,
                  taskComplexity = Nothing,
                  taskDependencies = [],
                  taskDescription = "Child 3",
                  taskComments = [],
                  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 Nothing [] "Child 4"
        taskId child4 Test.@?= taskId parent <> ".4",
      Test.unit "can edit task" <| do
        task <- createTask "Original Title" WorkTask Nothing Nothing P2 Nothing [] "Original"
        let modifyFn t = t {taskTitle = "New Title", taskPriority = P0}
        updated <- editTask (taskId task) modifyFn
        taskTitle updated Test.@?= "New Title"
        taskPriority updated Test.@?= P0
        -- Check persistence
        tasks <- loadTasks
        case findTask (taskId task) tasks of
          Nothing -> Test.assertFailure "Could not reload task"
          Just reloaded -> do
            taskTitle reloaded Test.@?= "New Title"
            taskPriority reloaded Test.@?= P0,
      Test.unit "task lookup is case insensitive" <| do
        task <- createTask "Case sensitive" WorkTask Nothing Nothing P2 Nothing [] "Case sensitive description"
        let tid = taskId task
            upperTid = T.toUpper tid
        tasks <- loadTasks
        let found = findTask upperTid tasks
        case found of
          Just t -> taskId t Test.@?= tid
          Nothing -> Test.assertFailure "Could not find task with upper case ID",
      Test.unit "namespace normalization handles .hs suffix" <| do
        let ns = "Omni/Task.hs"
            validNs = Namespace.fromHaskellModule ns
        Namespace.toPath validNs Test.@?= "Omni/Task.hs",
      Test.unit "generated IDs are lowercase" <| do
        task <- createTask "Lowercase check" WorkTask Nothing Nothing P2 Nothing [] "Lowercase description"
        let tid = taskId task
        tid Test.@?= T.toLower tid
        -- check it matches regex for base36 (t-[0-9a-z]+)
        let isLowerBase36 = T.all (\c -> c `elem` ['0' .. '9'] ++ ['a' .. 'z'] || c == 't' || c == '-') tid
        isLowerBase36 Test.@?= True,
      Test.unit "dependencies are case insensitive" <| do
        task1 <- createTask "Blocker" WorkTask Nothing Nothing P2 Nothing [] "Blocker description"
        let tid1 = taskId task1
            -- Use uppercase ID for dependency
            upperTid1 = T.toUpper tid1
            dep = Dependency {depId = upperTid1, depType = Blocks}
        task2 <- createTask "Blocked" WorkTask Nothing Nothing P2 Nothing [dep] "Blocked description"

        -- task1 is Open, so task2 should NOT be ready
        ready <- getReadyTasks
        (taskId task2 `notElem` map taskId ready) Test.@?= True

        updateTaskStatus tid1 Done []

        -- task2 should now be ready because dependency check normalizes IDs
        ready2 <- getReadyTasks
        (taskId task2 `elem` map taskId ready2) Test.@?= True,
      Test.unit "can create task with lowercase ID" <| do
        -- This verifies that lowercase IDs are accepted and not rejected
        let lowerId = "t-lowercase"
        let task = Task lowerId "Lower" WorkTask Nothing Nothing Open P2 Nothing [] "Lower description" [] (read "2025-01-01 00:00:00 UTC") (read "2025-01-01 00:00:00 UTC")
        saveTask task
        tasks <- loadTasks
        case findTask lowerId tasks of
          Just t -> taskId t Test.@?= lowerId
          Nothing -> Test.assertFailure "Should find task with lowercase ID",
      Test.unit "generateId produces valid ID" <| do
        tid <- generateId
        let task = Task tid "Auto" WorkTask Nothing Nothing Open P2 Nothing [] "Auto description" [] (read "2025-01-01 00:00:00 UTC") (read "2025-01-01 00:00:00 UTC")
        saveTask task
        tasks <- loadTasks
        case findTask tid tasks of
          Just _ -> pure ()
          Nothing -> Test.assertFailure "Should find generated task",
      Test.unit "generateId produces sequential IDs" <| do
        tid1 <- generateId
        tid2 <- generateId
        tid3 <- generateId
        T.isPrefixOf "t-" tid1 Test.@?= True
        T.isPrefixOf "t-" tid2 Test.@?= True
        T.isPrefixOf "t-" tid3 Test.@?= True
        let num1 = readMaybe (T.unpack (T.drop 2 tid1)) :: Maybe Int
            num2 = readMaybe (T.unpack (T.drop 2 tid2)) :: Maybe Int
            num3 = readMaybe (T.unpack (T.drop 2 tid3)) :: Maybe Int
        case (num1, num2, num3) of
          (Just n1, Just n2, Just n3) -> do
            (n2 == n1 + 1) Test.@?= True
            (n3 == n2 + 1) Test.@?= True
          _ -> Test.assertFailure "IDs should be sequential integers",
      Test.unit "lowercase ID does not clash with existing uppercase ID" <| do
        -- Setup: Create task with Uppercase ID
        let upperId = "t-UPPER"
        let task1 = Task upperId "Upper Task" WorkTask Nothing Nothing Open P2 Nothing [] "Upper description" [] (read "2025-01-01 00:00:00 UTC") (read "2025-01-01 00:00:00 UTC")
        saveTask task1

        -- Action: Try to create task with Lowercase ID (same letters)
        -- Note: In the current implementation, saveTask blindly appends.
        -- Ideally, we should be checking for existence if we want to avoid clash.
        -- OR, we accept that they are the SAME task and this is an update?
        -- But if they are different tasks (different titles, created at different times),
        -- treating them as the same is dangerous.

        let lowerId = "t-upper"
        let task2 = Task lowerId "Lower Task" WorkTask Nothing Nothing Open P2 Nothing [] "Lower description" [] (read "2025-01-01 00:00:01 UTC") (read "2025-01-01 00:00:01 UTC")
        saveTask task2

        tasks <- loadTasks
        -- What do we expect?
        -- If we expect them to be distinct:
        -- let foundUpper = List.find (\t -> taskId t == upperId) tasks
        -- let foundLower = List.find (\t -> taskId t == lowerId) tasks
        -- foundUpper /= Nothing
        -- foundLower /= Nothing

        -- BUT findTask uses case-insensitive search.
        -- So findTask upperId returns task1 (probably, as it's first).
        -- findTask lowerId returns task1.
        -- task2 is effectively hidden/lost to findTask.

        -- So, "do not clash" implies we shouldn't end up in this state.
        -- The test should probably fail if we have multiple tasks that match the same ID case-insensitively.

        let matches = filter (\t -> matchesId (taskId t) upperId) tasks
        length matches Test.@?= 2,
      Test.unit "FromHttpApiData Priority: empty string returns Left" <| do
        let result = parseQueryParam "" :: Either Text Priority
        case result of
          Left _ -> pure ()
          Right _ -> Test.assertFailure "Empty string should return Left",
      Test.unit "FromHttpApiData Priority: valid values parse correctly" <| do
        (parseQueryParam "P0" :: Either Text Priority) Test.@?= Right P0
        (parseQueryParam "P1" :: Either Text Priority) Test.@?= Right P1
        (parseQueryParam "P2" :: Either Text Priority) Test.@?= Right P2
        (parseQueryParam "P3" :: Either Text Priority) Test.@?= Right P3
        (parseQueryParam "P4" :: Either Text Priority) Test.@?= Right P4,
      Test.unit "FromHttpApiData Status: empty string returns Left" <| do
        let result = parseQueryParam "" :: Either Text Status
        case result of
          Left _ -> pure ()
          Right _ -> Test.assertFailure "Empty string should return Left",
      Test.unit "FromHttpApiData Status: valid values parse correctly" <| do
        (parseQueryParam "Open" :: Either Text Status) Test.@?= Right Open
        (parseQueryParam "InProgress" :: Either Text Status) Test.@?= Right InProgress
        (parseQueryParam "Done" :: Either Text Status) Test.@?= Right Done,
      Test.unit "can add comment to task" <| do
        task <- createTask "Task with comment" WorkTask Nothing Nothing P2 Nothing [] "Description"
        updatedTask <- addComment (taskId task) "This is a test comment"
        length (taskComments updatedTask) Test.@?= 1
        case taskComments updatedTask of
          (c : _) -> commentText c Test.@?= "This is a test comment"
          [] -> Test.assertFailure "Expected at least one comment",
      Test.unit "can add multiple comments to task" <| do
        task <- createTask "Task with comments" WorkTask Nothing Nothing P2 Nothing [] "Description"
        _ <- addComment (taskId task) "First comment"
        updatedTask <- addComment (taskId task) "Second comment"
        length (taskComments updatedTask) Test.@?= 2
        case taskComments updatedTask of
          (c1 : c2 : _) -> do
            commentText c1 Test.@?= "First comment"
            commentText c2 Test.@?= "Second comment"
          _ -> Test.assertFailure "Expected at least two comments",
      Test.unit "comments are persisted" <| do
        task <- createTask "Persistent comments" WorkTask Nothing Nothing P2 Nothing [] "Description"
        _ <- addComment (taskId task) "Persisted comment"
        tasks <- loadTasks
        case findTask (taskId task) tasks of
          Nothing -> Test.assertFailure "Could not reload task"
          Just reloaded -> do
            length (taskComments reloaded) Test.@?= 1
            case taskComments reloaded of
              (c : _) -> commentText c Test.@?= "Persisted comment"
              [] -> Test.assertFailure "Expected at least one comment"
    ]

-- | Test CLI argument parsing to ensure docopt string matches actual usage
cliTests :: Test.Tree
cliTests =
  Test.group
    "CLI argument parsing"
    [ Test.unit "init command" <| do
        let result = Docopt.parseArgs help ["init"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'init': " <> show err
          Right args -> args `Cli.has` Cli.command "init" Test.@?= True,
      Test.unit "init with --quiet flag" <| do
        let result = Docopt.parseArgs help ["init", "--quiet"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'init --quiet': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "init" Test.@?= True
            args `Cli.has` Cli.longOption "quiet" Test.@?= True,
      Test.unit "create with title" <| do
        let result = Docopt.parseArgs help ["create", "Test task"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'create': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "create" Test.@?= True
            Cli.getArg args (Cli.argument "title") Test.@?= Just "Test task",
      Test.unit "create with --json flag" <| do
        let result = Docopt.parseArgs help ["create", "Test", "--json"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'create --json': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "create" Test.@?= True
            args `Cli.has` Cli.longOption "json" Test.@?= True,
      Test.unit "create with --namespace flag" <| do
        let result = Docopt.parseArgs help ["create", "Test", "--namespace=Omni/Task"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'create --namespace': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "create" Test.@?= True
            Cli.getArg args (Cli.longOption "namespace") Test.@?= Just "Omni/Task",
      Test.unit "create with --discovered-from flag" <| do
        let result = Docopt.parseArgs help ["create", "Test", "--discovered-from=t-abc123"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'create --discovered-from': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "create" Test.@?= True
            Cli.getArg args (Cli.longOption "discovered-from") Test.@?= Just "t-abc123",
      Test.unit "create with --priority flag" <| do
        let result = Docopt.parseArgs help ["create", "Test", "--priority=1"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'create --priority': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "create" Test.@?= True
            Cli.getArg args (Cli.longOption "priority") Test.@?= Just "1",
      Test.unit "create with --complexity flag" <| do
        let result = Docopt.parseArgs help ["create", "Test", "--complexity=3"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'create --complexity': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "create" Test.@?= True
            Cli.getArg args (Cli.longOption "complexity") Test.@?= Just "3",
      Test.unit "edit with --complexity flag" <| do
        let result = Docopt.parseArgs help ["edit", "t-abc123", "--complexity=4"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'edit --complexity': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "edit" Test.@?= True
            Cli.getArg args (Cli.longOption "complexity") Test.@?= Just "4",
      Test.unit "edit command" <| do
        let result = Docopt.parseArgs help ["edit", "t-abc123"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'edit': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "edit" Test.@?= True
            Cli.getArg args (Cli.argument "id") Test.@?= Just "t-abc123",
      Test.unit "edit with options" <| do
        let result = Docopt.parseArgs help ["edit", "t-abc123", "--title=New Title", "--priority=0"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'edit' with options: " <> show err
          Right args -> do
            args `Cli.has` Cli.command "edit" Test.@?= True
            Cli.getArg args (Cli.longOption "title") Test.@?= Just "New Title"
            Cli.getArg args (Cli.longOption "priority") Test.@?= Just "0",
      Test.unit "list command" <| do
        let result = Docopt.parseArgs help ["list"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'list': " <> show err
          Right args -> args `Cli.has` Cli.command "list" Test.@?= True,
      Test.unit "list with --json flag" <| do
        let result = Docopt.parseArgs help ["list", "--json"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'list --json': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "list" Test.@?= True
            args `Cli.has` Cli.longOption "json" Test.@?= True,
      Test.unit "list with --status filter" <| do
        let result = Docopt.parseArgs help ["list", "--status=open"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'list --status': " <> show err
          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 "list with --status=draft filter" <| do
        let result = Docopt.parseArgs help ["list", "--status=draft"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'list --status=draft': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "list" Test.@?= True
            Cli.getArg args (Cli.longOption "status") Test.@?= Just "draft",
      Test.unit "ready command" <| do
        let result = Docopt.parseArgs help ["ready"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'ready': " <> show err
          Right args -> args `Cli.has` Cli.command "ready" Test.@?= True,
      Test.unit "ready with --json flag" <| do
        let result = Docopt.parseArgs help ["ready", "--json"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'ready --json': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "ready" Test.@?= True
            args `Cli.has` Cli.longOption "json" Test.@?= True,
      Test.unit "update command" <| do
        let result = Docopt.parseArgs help ["update", "t-abc123", "done"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'update': " <> 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 "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 command with draft" <| do
        let result = Docopt.parseArgs help ["update", "t-abc123", "draft"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'update ... draft': " <> 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 "draft",
      Test.unit "update with --json flag" <| do
        let result = Docopt.parseArgs help ["update", "t-abc123", "done", "--json"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'update --json': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "update" Test.@?= True
            args `Cli.has` Cli.longOption "json" Test.@?= True,
      Test.unit "update with --verified flag" <| do
        let result = Docopt.parseArgs help ["update", "t-abc123", "done", "--verified"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'update --verified': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "update" Test.@?= True
            args `Cli.has` Cli.longOption "verified" Test.@?= True,
      Test.unit "update with --verified and --json flags" <| do
        let result = Docopt.parseArgs help ["update", "t-abc123", "done", "--verified", "--json"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'update --verified --json': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "update" Test.@?= True
            args `Cli.has` Cli.longOption "verified" Test.@?= True
            args `Cli.has` Cli.longOption "json" Test.@?= True,
      Test.unit "deps command" <| do
        let result = Docopt.parseArgs help ["deps", "t-abc123"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'deps': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "deps" Test.@?= True
            Cli.getArg args (Cli.argument "id") Test.@?= Just "t-abc123",
      Test.unit "tree command" <| do
        let result = Docopt.parseArgs help ["tree"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'tree': " <> show err
          Right args -> args `Cli.has` Cli.command "tree" Test.@?= True,
      Test.unit "tree with id" <| do
        let result = Docopt.parseArgs help ["tree", "t-abc123"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'tree <id>': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "tree" Test.@?= True
            Cli.getArg args (Cli.argument "id") Test.@?= Just "t-abc123",
      Test.unit "export command" <| do
        let result = Docopt.parseArgs help ["export"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'export': " <> show err
          Right args -> args `Cli.has` Cli.command "export" Test.@?= True,
      Test.unit "import command" <| do
        let result = Docopt.parseArgs help ["import", "-i", "tasks.jsonl"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'import': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "import" Test.@?= True
            -- Note: -i is a short option, not an argument
            Cli.getArg args (Cli.shortOption 'i') Test.@?= Just "tasks.jsonl",
      Test.unit "show command" <| do
        let result = Docopt.parseArgs help ["show", "t-abc123"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'show': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "show" Test.@?= True
            Cli.getArg args (Cli.argument "id") Test.@?= Just "t-abc123",
      Test.unit "show with --json flag" <| do
        let result = Docopt.parseArgs help ["show", "t-abc123", "--json"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'show --json': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "show" Test.@?= True
            args `Cli.has` Cli.longOption "json" Test.@?= True,
      Test.unit "stats command" <| do
        let result = Docopt.parseArgs help ["stats"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'stats': " <> show err
          Right args -> args `Cli.has` Cli.command "stats" Test.@?= True,
      Test.unit "stats with --json flag" <| do
        let result = Docopt.parseArgs help ["stats", "--json"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'stats --json': " <> show err
          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
          Left err -> Test.assertFailure <| "Failed to parse 'create' with reordered flags: " <> show err
          Right args -> do
            args `Cli.has` Cli.command "create" Test.@?= True
            args `Cli.has` Cli.longOption "json" Test.@?= True
            Cli.getArg args (Cli.longOption "priority") Test.@?= Just "1"
            Cli.getArg args (Cli.longOption "namespace") Test.@?= Just "Omni/Task",
      Test.unit "comment command" <| do
        let result = Docopt.parseArgs help ["comment", "t-abc123", "This is a comment"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'comment': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "comment" Test.@?= True
            Cli.getArg args (Cli.argument "id") Test.@?= Just "t-abc123"
            Cli.getArg args (Cli.argument "message") Test.@?= Just "This is a comment",
      Test.unit "comment with --json flag" <| do
        let result = Docopt.parseArgs help ["comment", "t-abc123", "Test comment", "--json"]
        case result of
          Left err -> Test.assertFailure <| "Failed to parse 'comment --json': " <> show err
          Right args -> do
            args `Cli.has` Cli.command "comment" Test.@?= True
            args `Cli.has` Cli.longOption "json" Test.@?= True
    ]