diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,7 @@
+## [_Unreleased_](https://github.com/frontrowed/faktory_worker_haskell/compare/v1.0.0.0...main)
+
+None
+
+## [v1.0.0.0](https://github.com/frontrowed/faktory_worker_haskell/tree/v1.0.0.0)
+
+Initial release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2020 Renaissance Learning Inc
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,20 @@
+# nonempty-zipper
+
+A non-empty comonadic list zipper
+
+## Why not another Zipper implementation?
+
+A [Hoogle search][hoogle-search] reveals various implementations of zippers.
+
+The two that have the most similar feature sets to `nonempty-zipper` are
+[list-zipper][list-zipper] and [non-empty-zipper][non-empty-zipper].
+
+[list-zipper][list-zipper] has a solid lens interface, and a ton of instances,
+however it's lacking documentation and an `NFData` instance.
+
+[non-empty-zipper][non-empty-zipper] is most similar to this package but it
+appears to be unmaintained (e.g. `base` has drifted).
+
+  [hoogle-search]: https://hoogle.haskell.org/?hoogle=zipper
+  [list-zipper]: https://hackage.haskell.org/package/list-zipper
+  [non-empty-zipper]: https://hackage.haskell.org/package/non-empty-zipper
diff --git a/doctest/Main.hs b/doctest/Main.hs
new file mode 100644
--- /dev/null
+++ b/doctest/Main.hs
@@ -0,0 +1,9 @@
+module Main (main) where
+
+import System.FilePath.Glob
+import Test.DocTest
+
+main :: IO ()
+main = do
+  paths <- globDir1 (compile "**/*.hs") "library"
+  doctest paths
diff --git a/library/Data/List/NonEmpty/Zipper.hs b/library/Data/List/NonEmpty/Zipper.hs
new file mode 100644
--- /dev/null
+++ b/library/Data/List/NonEmpty/Zipper.hs
@@ -0,0 +1,283 @@
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveFunctor #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+
+module Data.List.NonEmpty.Zipper
+  ( Zipper
+
+  -- * Accessors
+  , lefts
+  , rights
+  , current
+
+  -- * Traversal
+  , left
+  , right
+  , findLeft
+  , findRight
+  , start
+  , end
+
+  -- * Construction
+  , fromNonEmpty
+  , fromNonEmptyEnd
+
+  -- ** Update
+  , replace
+  , delete
+  , push
+  , pop
+  , shift
+  , unshift
+  , reverse
+
+  -- * Predicates
+  , isStart
+  , isEnd
+  )
+where
+
+import Prelude hiding (reverse)
+import qualified Prelude
+
+import Control.Comonad
+import Control.DeepSeq (NFData)
+import qualified Data.List.NonEmpty as NE
+import Data.Maybe (fromMaybe)
+import GHC.Generics (Generic)
+import Safe (headMay, tailMay)
+
+data Zipper a = Zipper [a] a [a]
+  deriving stock (Eq, Show, Functor, Generic)
+  deriving anyclass (NFData)
+
+instance Foldable Zipper where
+  foldMap f (Zipper ls x rs) = foldMap f (Prelude.reverse ls) `mappend` f x `mappend` foldMap f rs
+
+instance Traversable Zipper where
+  traverse f (Zipper ls x rs) =
+    Zipper
+      <$> (Prelude.reverse <$> traverse f (Prelude.reverse ls))
+      <*> f x
+      <*> traverse f rs
+
+-- | The list zipper is a basic comonad
+--
+-- This instance allows us to create a zipper of all possible states of
+-- traversing the zipper with 'left' and 'right'.
+--
+-- >>> duplicate $ fromNonEmpty $ NE.fromList [1, 2, 3]
+-- Zipper [] (Zipper [] 1 [2,3]) [Zipper [1] 2 [3],Zipper [2,1] 3 []]
+--
+instance Comonad Zipper where
+  extract = current
+  duplicate z =
+    let dupWith f r =
+          case f r of
+            Nothing -> [r]
+            Just x -> r:dupWith f x
+    in Zipper
+      (maybe [] (dupWith left) $ left z)
+      z
+      (maybe [] (dupWith right) $ right z)
+
+-- | Get the current focus of the @'Zipper'@ cursor
+--
+-- This is a synonym for 'Control.Comonad.extract'
+--
+-- >>> current . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- 1
+--
+current :: Zipper a -> a
+current (Zipper _ curr _) = curr
+
+-- | Get all values on the left of the cursor
+--
+-- >>> lefts . fromNonEmptyEnd $ NE.fromList [1, 2, 3]
+-- [1,2]
+--
+lefts :: Zipper a -> [a]
+lefts (Zipper ls _ _) = Prelude.reverse ls
+
+-- | Get all values on the right of the cursor
+--
+-- >>> rights . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- [2,3]
+--
+rights :: Zipper a -> [a]
+rights (Zipper _ _ rs) = rs
+
+
+-- | Move the current focus of the cursor to the left
+--
+-- >>> left . fromNonEmptyEnd $ NE.fromList [1, 2, 3]
+-- Just (Zipper [1] 2 [3])
+--
+left :: Zipper a -> Maybe (Zipper a)
+left (Zipper ps curr ns) = do
+  newCurr <- headMay ps
+  pure $ Zipper (fromMaybe [] $ tailMay ps) newCurr (curr : ns)
+
+-- | Move the current focus of the cursor to the right
+--
+-- >>> right . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- Just (Zipper [1] 2 [3])
+--
+right :: Zipper a -> Maybe (Zipper a)
+right (Zipper ps curr ns) = do
+  newCurr <- headMay ns
+  pure $ Zipper (curr : ps) newCurr (fromMaybe [] $ tailMay ns)
+
+-- | Move the current focus of the cursor to the first occurence of a value on the left
+--
+-- >>> findLeft 2 . fromNonEmptyEnd $ NE.fromList [2, 1, 2, 1, 1, 3]
+-- Just (Zipper [1,2] 2 [1,1,3])
+--
+findLeft :: Eq a => a -> Zipper a -> Maybe (Zipper a)
+findLeft target z@(Zipper ps curr ns)
+  | curr == target = Just z
+  | otherwise = case ps of
+    [] -> Nothing
+    (x : xs) -> findLeft target (Zipper xs x (curr : ns))
+
+-- | Move the current focus of the cursor to the first occurence of a value on the right
+--
+-- >>> findRight 3 . fromNonEmpty $ NE.fromList [2, 1, 3, 1, 1, 3]
+-- Just (Zipper [1,2] 3 [1,1,3])
+--
+findRight :: Eq a => a -> Zipper a -> Maybe (Zipper a)
+findRight target z@(Zipper ps curr ns)
+  | curr == target = Just z
+  | otherwise = case ns of
+    [] -> Nothing
+    (x : xs) -> findRight target (Zipper (curr : ps) x xs)
+
+-- | Move the current focus of the cursor to the start of the @'Zipper'@
+--
+-- >>> start . fromNonEmptyEnd $ NE.fromList [1, 2, 3]
+-- Zipper [] 1 [2,3]
+--
+start :: Zipper a -> Zipper a
+start z
+  | isStart z = z
+  | otherwise = fromNonEmpty $ toNonEmpty z
+
+-- | Move the current focus of the cursor to the end of the @'Zipper'@
+--
+-- >>> end . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- Zipper [2,1] 3 []
+--
+end :: Zipper a -> Zipper a
+end z
+  | isEnd z = z
+  | otherwise = fromNonEmptyEnd $ toNonEmpty z
+
+
+fromNonEmpty :: NE.NonEmpty a -> Zipper a
+fromNonEmpty ne = Zipper [] (NE.head ne) (NE.tail ne)
+
+fromNonEmptyEnd :: NE.NonEmpty a -> Zipper a
+fromNonEmptyEnd ne = Zipper (NE.tail reversed) (NE.head reversed) []
+  where reversed = NE.reverse ne
+
+toNonEmpty :: Zipper a -> NE.NonEmpty a
+toNonEmpty (Zipper ls x rs) = NE.fromList $ Prelude.reverse ls ++ [x] ++ rs
+
+
+-- | Replace the current item under the curosr
+--
+-- >>> replace 4 . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- Zipper [] 4 [2,3]
+--
+replace :: a -> Zipper a -> Zipper a
+replace x (Zipper ls _ rs) = Zipper ls x rs
+
+-- | Delete the item currently under the cursor
+--
+-- The item currently under the cursor is removed. The cursors focus will move
+-- right. If at the end of the @'Zipper'@ the cursor will move left.
+--
+-- >>> delete . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- Just (Zipper [] 2 [3])
+--
+-- >>> delete . fromNonEmptyEnd $ NE.fromList [1, 2, 3]
+-- Just (Zipper [1] 2 [])
+--
+delete :: Zipper a -> Maybe (Zipper a)
+delete (Zipper [] _ []) = Nothing
+delete (Zipper ls _ (r : rs)) = Just $ Zipper ls r rs
+delete (Zipper (l : ls) _ rs) = Just $ Zipper ls l rs
+
+-- | Insert a value to the left of the cursor
+--
+-- >>> push 0 . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- Zipper [0] 1 [2,3]
+--
+push :: a -> Zipper a -> Zipper a
+push l (Zipper ls x rs) = Zipper (l : ls) x rs
+
+-- | Remove a value to the left of the cursor
+--
+-- >>> pop . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- (Zipper [] 1 [2,3],Nothing)
+--
+-- >>> pop . fromNonEmptyEnd $ NE.fromList [1, 2, 3]
+-- (Zipper [1] 3 [],Just 2)
+--
+pop :: Zipper a -> (Zipper a, Maybe a)
+pop (Zipper [] x rs) = (Zipper [] x rs, Nothing)
+pop (Zipper (l : ls) x rs) = (Zipper ls x rs, Just l)
+
+-- | Remove a value to the right of the cursor
+--
+-- >>> shift . fromNonEmptyEnd $ NE.fromList [1, 2, 3]
+-- (Zipper [2,1] 3 [],Nothing)
+--
+-- >>> shift . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- (Zipper [] 1 [3],Just 2)
+--
+shift :: Zipper a -> (Zipper a, Maybe a)
+shift (Zipper ls x []) = (Zipper ls x [], Nothing)
+shift (Zipper ls x (r : rs)) = (Zipper ls x rs, Just r)
+
+-- | Insert a value to the right of the cursor
+--
+-- >>> unshift 4 . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- Zipper [] 1 [4,2,3]
+--
+unshift :: a -> Zipper a -> Zipper a
+unshift r (Zipper ls x rs) = Zipper ls x (r : rs)
+
+-- | Reverse the zipper keeping the cursor focus intact
+--
+-- >>> reverse . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- Zipper [2,3] 1 []
+--
+reverse :: Zipper a -> Zipper a
+reverse (Zipper ls x rs) = Zipper rs x ls
+
+
+-- | Determine if the @'Zipper'@ is at the beginning
+--
+-- >>> isStart . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- True
+--
+-- >>> isStart . fromNonEmptyEnd $ NE.fromList [1, 2, 3]
+-- False
+--
+isStart :: Zipper a -> Bool
+isStart (Zipper [] _ _) = True
+isStart _ = False
+
+-- | Determine if the @'Zipper'@ is at the end
+--
+-- >>> isEnd . fromNonEmptyEnd $ NE.fromList [1, 2, 3]
+-- True
+--
+-- >>> isEnd . fromNonEmpty $ NE.fromList [1, 2, 3]
+-- False
+--
+isEnd :: Zipper a -> Bool
+isEnd (Zipper _ _ []) = True
+isEnd _ = False
diff --git a/nonempty-zipper.cabal b/nonempty-zipper.cabal
new file mode 100644
--- /dev/null
+++ b/nonempty-zipper.cabal
@@ -0,0 +1,48 @@
+cabal-version:      1.12
+name:               nonempty-zipper
+version:            1.0.0.0
+license:            MIT
+license-file:       LICENSE
+copyright:          2021 Renaissance Learning Inc
+maintainer:         engineering@freckle.com
+author:             Freckle R&D
+homepage:           https://github.com/freckle/nonempty-zipper#readme
+bug-reports:        https://github.com/freckle/nonempty-zipper/issues
+synopsis:           A non-empty comonadic list zipper
+description:        A non-empty comonadic list zipper
+category:           Data, Data Structures
+build-type:         Simple
+extra-source-files:
+    README.md
+    CHANGELOG.md
+
+source-repository head
+    type:     git
+    location: https://github.com/freckle/nonempty-zipper
+
+library
+    exposed-modules:  Data.List.NonEmpty.Zipper
+    hs-source-dirs:   library
+    other-modules:    Paths_nonempty_zipper
+    default-language: Haskell2010
+    ghc-options:      -Wall
+    build-depends:
+        base >=4.14.1.0 && <4.15,
+        comonad >=5.0.8 && <5.1,
+        deepseq >=1.4.4.0 && <1.5,
+        safe >=0.3.19 && <0.4
+
+test-suite doctest
+    type:             exitcode-stdio-1.0
+    main-is:          Main.hs
+    hs-source-dirs:   doctest
+    other-modules:    Paths_nonempty_zipper
+    default-language: Haskell2010
+    ghc-options:      -Wall
+    build-depends:
+        Glob >=0.10.1 && <0.11,
+        base >=4.14.1.0 && <4.15,
+        comonad >=5.0.8 && <5.1,
+        deepseq >=1.4.4.0 && <1.5,
+        doctest >=0.16.3 && <0.17,
+        safe >=0.3.19 && <0.4
