diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
 # Revision history for scripths
 
+## 0.3.3.0 -- 2026-05-24
+
+* Build against **local packages**: auto-include the enclosing cabal project when a
+  `build-depends` names it (zero-config; `--no-local-project` to disable), and a
+  repeatable `--package DIR` / `-p DIR` flag for sibling/sub-library checkouts. Lets
+  documentation build against an unreleased local working tree.
+* New `-- cabal: source-repository-package: <location> <ref> [subdir]` directive for
+  pinning a pushed git commit/tag reproducibly; rendered into the generated
+  `cabal.project`.
+* `CabalMeta` gains `metaSourceRepos` and `metaUnknownKeys`; unrecognised `-- cabal:`
+  keys now warn to stderr (instead of being silently dropped).
+* The generated `cabal.project` is regenerated each run (guarded by a `-- managed by
+  scripths` sentinel so hand-edited project files are preserved), replacing the old
+  write-once behaviour. Rewrote the CLI argument parser.
+
 ## 0.3.1.0 -- 2026-04-23
 
 * Better block parsing when there are function signatures and comments.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
 ## CLI Usage
 
 ```
-scripths [--output=<filename>] [-o <filename>] <script>
+scripths [-o FILE | --output=FILE] [-p DIR | --package DIR]... [--no-local-project] <script>
 ```
 
 When `-o` / `--output` is provided for Markdown files, the result is written to that path. Otherwise it is printed to stdout. The file extension determines the mode. `.ghci` / `hs` files are parsed and executed as a standalone GHCi script. `.md` / `.markdown` files are processed as a notebook with captured output.
@@ -97,3 +97,51 @@
 -- cabal: default-extensions: OverloadedStrings, TypeApplications
 -- cabal: ghc-options: -Wall
 ```
+
+## Local packages
+
+By default `build-depends` resolve from Hackage. To build a script or notebook
+against a **local checkout** instead — e.g. to document a library version that
+only exists on a branch — there are two mechanisms.
+
+### The enclosing project (zero-config)
+
+If a script/notebook lives inside a cabal project and a `build-depends` names that
+project's package (or one of its sub-libraries, `pkg:sublib`), scripths builds
+against the local working tree automatically — so documenting your own repo just
+works:
+
+```bash
+scripths -o docs/MANUAL.md docs/base/MANUAL.md
+```
+
+Pass `--no-local-project` to suppress this and resolve every `build-depends` from
+Hackage instead — e.g. to verify the doc's examples against the *released*
+version of your package rather than the working tree. Explicit `--package` dirs
+are unaffected.
+
+### `--package DIR`
+
+Point at another local checkout (a sibling package, or a sub-library project).
+`DIR` must be a package root (contain a `.cabal`); it is resolved to an absolute
+path at the invocation site, so nothing machine-specific is committed to the
+shared document. Repeatable.
+
+```bash
+scripths --package ../granite -o out.md in.md
+```
+
+### Pinned git packages (`source-repository-package`)
+
+To pin a **pushed** commit/tag reproducibly (and commit the pin into the shared
+document), declare a `source-repository-package` directive as
+`<location> <ref> [subdir]`:
+
+```
+-- cabal: source-repository-package: https://github.com/owner/repo v1.2.3
+-- cabal: source-repository-package: https://github.com/owner/mono abc123 sub/dir
+```
+
+scripths writes these as `source-repository-package` stanzas in the generated
+`cabal.project`. Use a local checkout (above) for an **unpushed** branch, and a git
+pin for a **pushed**, reproducible reference.
diff --git a/app/Main.hs b/app/Main.hs
--- a/app/Main.hs
+++ b/app/Main.hs
@@ -1,48 +1,103 @@
 module Main where
 
+import Control.Monad (unless, when)
 import Data.List (isPrefixOf)
 import qualified Data.Text.IO as TIO
+import System.Directory (
+    doesDirectoryExist,
+    listDirectory,
+    makeAbsolute,
+ )
 import System.Environment (getArgs, getProgName)
 import System.Exit (exitFailure)
 import System.FilePath (takeExtension)
+import System.IO (hPutStrLn, stderr)
 
 import ScriptHs.Notebook (runNotebook)
 import ScriptHs.Parser (parseScript)
-import ScriptHs.Run (runScript)
+import ScriptHs.Run (RunOptions (..), defaultRunOptions, runScript)
 
+-- | Parsed command-line options.
+data Args = Args
+    { argScript :: Maybe FilePath
+    , argOutput :: Maybe FilePath
+    , argPackages :: [FilePath]
+    , argNoLocalProject :: Bool
+    }
+
+emptyArgs :: Args
+emptyArgs = Args Nothing Nothing [] False
+
 main :: IO ()
 main = do
-    args <- getArgs
-    case args of
-        [path] -> dispatch path Nothing
-        [] -> usage
-        _ -> dispatch (last args) (outputFile (init args))
+    raw <- getArgs
+    case parseArgs raw of
+        Left err -> hPutStrLn stderr ("scripths: " ++ err) >> usage
+        Right a -> case argScript a of
+            Nothing -> usage
+            Just path -> do
+                pkgs <- mapM resolvePackageDir (argPackages a)
+                let opts =
+                        defaultRunOptions
+                            { roPackages = pkgs
+                            , roEnclosingProject = not (argNoLocalProject a)
+                            }
+                dispatch opts path (argOutput a)
 
-dispatch :: FilePath -> Maybe FilePath -> IO ()
-dispatch path outputPath =
+dispatch :: RunOptions -> FilePath -> Maybe FilePath -> IO ()
+dispatch opts path outputPath =
     case takeExtension path of
-        ".md" -> runNotebook path outputPath
-        ".markdown" -> runNotebook path outputPath
+        ".md" -> runNotebook opts path outputPath
+        ".markdown" -> runNotebook opts path outputPath
         _ -> do
             contents <- TIO.readFile path
             let sf = parseScript contents
-            runScript path sf
+            runScript opts path sf
 
--- TODO: This is kinda brittle but I don't wanna
--- include the whole opt parser lib just to
--- get the output file. Maybe there's a cheaper
--- way to do this.
-outputFile :: [String] -> Maybe FilePath
-outputFile [] = Nothing
-outputFile ("-o" : f : _) = Just f
-outputFile (x : xs) =
-    if "--output=" `isPrefixOf` x
-        then Just (drop (length "--output=") x)
-        else outputFile (drop 1 xs)
+{- | Parse args: @[-o FILE | --output=FILE] [--package DIR | -p DIR |
+--package=DIR]... [--no-local-project] <script>@. The script is the sole
+non-flag argument.
+-}
+parseArgs :: [String] -> Either String Args
+parseArgs = go emptyArgs
+  where
+    go a [] = Right a
+    go a ("-o" : f : rest) = go a{argOutput = Just f} rest
+    go _ ["-o"] = Left "-o requires a filename"
+    go a (tok : rest)
+        | tok == "-p" || tok == "--package" = case rest of
+            (d : rest') -> go a{argPackages = argPackages a ++ [d]} rest'
+            [] -> Left (tok ++ " requires a directory")
+        | Just f <- stripFlag "--output=" tok = go a{argOutput = Just f} rest
+        | Just d <- stripFlag "--package=" tok =
+            go a{argPackages = argPackages a ++ [d]} rest
+        | tok == "--no-local-project" = go a{argNoLocalProject = True} rest
+        | "-" `isPrefixOf` tok = Left ("unknown flag: " ++ tok)
+        | otherwise = case argScript a of
+            Nothing -> go a{argScript = Just tok} rest
+            Just _ -> Left ("unexpected extra argument: " ++ tok)
+    stripFlag p s = if p `isPrefixOf` s then Just (drop (length p) s) else Nothing
 
+-- | Resolve a @--package@ dir to absolute, requiring a package root (a @.cabal@).
+resolvePackageDir :: FilePath -> IO FilePath
+resolvePackageDir dir = do
+    path <- makeAbsolute dir
+    isDir <- doesDirectoryExist path
+    unless isDir (die ("--package: not a directory: " ++ path))
+    cabals <- filter ((== ".cabal") . takeExtension) <$> listDirectory path
+    when (null cabals) $
+        die ("--package: no .cabal file in " ++ path ++ " (expected a package root)")
+    pure path
+
+die :: String -> IO a
+die msg = hPutStrLn stderr ("scripths: " ++ msg) >> exitFailure
+
 usage :: IO ()
 usage = do
     prog <- getProgName
     putStrLn $
-        "Usage: " ++ prog ++ " [--output=<filename>] [-o <filename>] <script>"
+        "Usage: "
+            ++ prog
+            ++ " [-o FILE | --output=FILE] [-p DIR | --package DIR]..."
+            ++ " [--no-local-project] <script>"
     exitFailure
diff --git a/scripths.cabal b/scripths.cabal
--- a/scripths.cabal
+++ b/scripths.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.0
 name:               scripths
-version:            0.3.2.0
+version:            0.3.3.0
 synopsis:           GHCi scripts for standalone execution and Markdown documentation.
 description:        GHCi scripts for standalone execution (with dependency resolution) and Markdown documentation (produces inline output).
 homepage:           https://www.datahaskell.org/
@@ -43,6 +43,7 @@
     build-depends:
         base >= 4 && < 5,
         scripths,
+        directory >= 1.3 && < 1.4,
         filepath >= 1.4 && < 1.6,
         text >= 2.0 && < 3,
 
diff --git a/src/ScriptHs/Notebook.hs b/src/ScriptHs/Notebook.hs
--- a/src/ScriptHs/Notebook.hs
+++ b/src/ScriptHs/Notebook.hs
@@ -19,33 +19,38 @@
     mergeMetas,
     parseScript,
  )
-import ScriptHs.Run (runScriptCapture)
+import ScriptHs.Run (RunOptions, runScriptCapture)
 
 type IndexedSegments = [(Int, Segment)]
 type IndexedBlocks = [(Int, [Line])]
 
-runNotebook :: FilePath -> Maybe FilePath -> IO ()
-runNotebook path outputPath = do
+runNotebook :: RunOptions -> FilePath -> Maybe FilePath -> IO ()
+runNotebook opts path outputPath = do
     contents <- TIO.readFile path
-    outputMd <- processNotebook path contents
+    outputMd <- processNotebook opts path contents
     case outputPath of
         Nothing -> TIO.putStr outputMd
         Just output -> TIO.writeFile output outputMd
 
-processNotebook :: FilePath -> Text -> IO Text
-processNotebook notebookPath contents = do
+processNotebook :: RunOptions -> FilePath -> Text -> IO Text
+processNotebook opts notebookPath contents = do
     let indexedSegments = zip [0 ..] (parseMarkdown contents)
         (metas, indexedCodeBlocks) = parseBlocks indexedSegments
     if null indexedCodeBlocks
         then pure contents
-        else executeCodeCells notebookPath metas indexedSegments indexedCodeBlocks
+        else executeCodeCells opts notebookPath metas indexedSegments indexedCodeBlocks
 
 executeCodeCells ::
-    FilePath -> CabalMeta -> IndexedSegments -> IndexedBlocks -> IO Text
-executeCodeCells notebookPath meta allSegments codeBlocks = do
+    RunOptions ->
+    FilePath ->
+    CabalMeta ->
+    IndexedSegments ->
+    IndexedBlocks ->
+    IO Text
+executeCodeCells opts notebookPath meta allSegments codeBlocks = do
     let ghciScript = generatedMarkedScript codeBlocks
         sf = ScriptFile{scriptMeta = meta, scriptLines = ghciScript}
-    rawOutput <- runScriptCapture notebookPath sf
+    rawOutput <- runScriptCapture opts notebookPath sf
     let outputs = splitByMarkers rawOutput (map fst codeBlocks)
         blocksWithOutput = addOutputToSegments outputs allSegments
     pure $ reassemble blocksWithOutput
diff --git a/src/ScriptHs/Parser.hs b/src/ScriptHs/Parser.hs
--- a/src/ScriptHs/Parser.hs
+++ b/src/ScriptHs/Parser.hs
@@ -18,11 +18,13 @@
 module ScriptHs.Parser (
     ScriptFile (..),
     CabalMeta (..),
+    SourceRepoPin (..),
     Line (..),
     parseScript,
     mergeMetas,
 ) where
 
+import Data.Maybe
 import Data.Text (Text)
 import qualified Data.Text as T
 
@@ -55,9 +57,32 @@
     -- ^ Extensions from @default-extensions@ directives.
     , metaGhcOptions :: [Text]
     -- ^ Flags from @ghc-options@ directives.
+    , metaSourceRepos :: [SourceRepoPin]
+    -- ^ Pinned git packages from @source-repository-package@ directives.
+    , metaUnknownKeys :: [Text]
+    -- ^ Unrecognised @-- cabal:@ directive keys (surfaced as warnings).
     }
     deriving (Show, Eq)
 
+{- | A git @source-repository-package@ pin, declared with
+
+@
+-- cabal: source-repository-package: <location> <ref> [subdir]
+@
+
+so documentation can build against a pinned, pushed commit/tag (reproducible
+and commit-safe, unlike a local working-tree path).
+-}
+data SourceRepoPin = SourceRepoPin
+    { srpLocation :: Text
+    -- ^ Repository location (e.g. a git URL).
+    , srpRef :: Text
+    -- ^ Commit hash or tag (becomes cabal's @tag:@).
+    , srpSubdir :: Maybe Text
+    -- ^ Optional subdirectory within the repository.
+    }
+    deriving (Show, Eq)
+
 -- | A single logical line from the script body.
 data Line
     = -- | An empty or whitespace-only line.
@@ -80,10 +105,10 @@
 Example:
 
 >>> parseScript "-- cabal: build-depends: text\nimport Data.Text (Text)\n"
-Right (ScriptFile {scriptMeta = CabalMeta {metaDeps = ["text"], metaExts = [], metaGhcOptions = []}, scriptLines = [Import "import Data.Text (Text)"]})
+ScriptFile {scriptMeta = CabalMeta {metaDeps = ["text"], metaExts = [], metaGhcOptions = [], metaSourceRepos = [], metaUnknownKeys = []}, scriptLines = [Import "import Data.Text (Text)"]}
 
 >>> parseScript ""
-Right (ScriptFile {scriptMeta = CabalMeta {metaDeps = [], metaExts = [], metaGhcOptions = []}, scriptLines = []})
+ScriptFile {scriptMeta = CabalMeta {metaDeps = [], metaExts = [], metaGhcOptions = [], metaSourceRepos = [], metaUnknownKeys = []}, scriptLines = []}
 -}
 parseScript :: Text -> ScriptFile
 parseScript input =
@@ -109,6 +134,8 @@
         { metaDeps = concatMap metaDeps ms
         , metaExts = concatMap metaExts ms
         , metaGhcOptions = concatMap metaGhcOptions ms
+        , metaSourceRepos = concatMap metaSourceRepos ms
+        , metaUnknownKeys = concatMap metaUnknownKeys ms
         }
 
 parseLine :: Text -> RawLine
@@ -128,10 +155,35 @@
                 "build-depends" -> emptyCabal{metaDeps = items}
                 "default-extensions" -> emptyCabal{metaExts = items}
                 "ghc-options" -> emptyCabal{metaGhcOptions = items}
-                _ -> emptyCabal
+                "source-repository-package" ->
+                    emptyCabal{metaSourceRepos = maybeToList (parseSourceRepo value)}
+                other -> emptyCabal{metaUnknownKeys = [other]}
         _ -> Nothing
   where
-    emptyCabal = CabalMeta{metaDeps = [], metaExts = [], metaGhcOptions = []}
+    emptyCabal =
+        CabalMeta
+            { metaDeps = []
+            , metaExts = []
+            , metaGhcOptions = []
+            , metaSourceRepos = []
+            , metaUnknownKeys = []
+            }
+
+{- | Parse a @source-repository-package@ value: whitespace-separated
+@\<location\> \<ref\> [subdir]@ (not comma-separated like the other directives).
+-}
+parseSourceRepo :: Text -> Maybe SourceRepoPin
+parseSourceRepo value = case T.words value of
+    (loc : ref : rest) ->
+        Just
+            SourceRepoPin
+                { srpLocation = loc
+                , srpRef = ref
+                , srpSubdir = case rest of
+                    (sub : _) -> Just sub
+                    [] -> Nothing
+                }
+    _ -> Nothing
 
 parseCodeLine :: Text -> Line
 parseCodeLine line
diff --git a/src/ScriptHs/Render.hs b/src/ScriptHs/Render.hs
--- a/src/ScriptHs/Render.hs
+++ b/src/ScriptHs/Render.hs
@@ -34,9 +34,10 @@
 
 import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
 import Data.List (intercalate)
+import Data.Maybe
 import Data.Text (Text)
 import qualified Data.Text as T
-import ScriptHs.Parser (CabalMeta (..), Line (..))
+import ScriptHs.Parser (CabalMeta (..), Line (..), SourceRepoPin (..))
 
 data Block
     = SingleLine Line
@@ -489,16 +490,23 @@
 the exporter over-includes imports to keep dependency slices self-contained.
 -}
 renderCabalScriptHeader :: CabalMeta -> Text
-renderCabalScriptHeader (CabalMeta deps exts opts) =
+renderCabalScriptHeader meta =
     T.unlines $
-        ["{- cabal:", "build-depends: " <> commaList (dedup ("base" : deps))]
+        ["{- cabal:", "build-depends: " <> commaList (dedup ("base" : metaDeps meta))]
             ++ ["default-extensions: " <> commaList exts' | not (null exts')]
             ++ ["ghc-options: " <> T.unwords opts']
+            ++ map renderRepo (metaSourceRepos meta)
             ++ ["-}"]
   where
-    exts' = dedup exts
-    opts' = dedup (opts ++ ["-Wno-unused-imports"])
+    exts' = dedup (metaExts meta)
+    opts' = dedup (metaGhcOptions meta ++ ["-Wno-unused-imports"])
     commaList = T.intercalate ", "
+    renderRepo r =
+        T.unwords $
+            "source-repository-package:"
+                : srpLocation r
+                : srpRef r
+                : maybeToList (srpSubdir r)
 
 -- | A runnable single-file cabal script: a @{\- cabal: -\}@ header over a module.
 renderCabalScript :: CabalMeta -> ModuleParts -> Text
diff --git a/src/ScriptHs/Run.hs b/src/ScriptHs/Run.hs
--- a/src/ScriptHs/Run.hs
+++ b/src/ScriptHs/Run.hs
@@ -2,15 +2,17 @@
 
 module ScriptHs.Run where
 
-import Control.Monad (unless)
+import Control.Monad (filterM, unless, when)
 import Data.Char (isAlphaNum)
-import Data.List (intercalate)
+import Data.List (intercalate, nub)
+import Data.Maybe (listToMaybe)
 import qualified Data.Text as T
 import qualified Data.Text.IO as TIO
 import ScriptHs.Parser (
     CabalMeta (..),
     Line,
     ScriptFile (scriptLines, scriptMeta),
+    SourceRepoPin (..),
  )
 import ScriptHs.Render (toGhciScript)
 import System.Directory (
@@ -18,10 +20,11 @@
     doesFileExist,
     getCurrentDirectory,
     getHomeDirectory,
+    listDirectory,
     makeAbsolute,
  )
 import System.Exit (ExitCode (..), exitWith)
-import System.FilePath ((</>))
+import System.FilePath (takeDirectory, takeExtension, (</>))
 import System.IO (stderr)
 import System.Process (
     CreateProcess (cwd, delegate_ctlc),
@@ -31,40 +34,153 @@
     waitForProcess,
  )
 
-runScript :: FilePath -> ScriptFile -> IO ()
+-- | Options controlling how the throwaway cabal project is assembled.
+data RunOptions = RunOptions
+    { roPackages :: [FilePath]
+    -- ^ Local package directories to add to @packages:@ (already absolute).
+    , roEnclosingProject :: Bool
+    {- ^ Auto-include the enclosing cabal project when a @build-depends@ names
+    it (the zero-config default). Disabled by @--no-local-project@.
+    -}
+    }
+    deriving (Show, Eq)
+
+-- | No explicit @--package@ dirs; enclosing-project auto-detection on.
+defaultRunOptions :: RunOptions
+defaultRunOptions = RunOptions{roPackages = [], roEnclosingProject = True}
+
+runScript :: RunOptions -> FilePath -> ScriptFile -> IO ()
 runScript = runWithCont runGhc
 
-runScriptCapture :: FilePath -> ScriptFile -> IO T.Text
+runScriptCapture :: RunOptions -> FilePath -> ScriptFile -> IO T.Text
 runScriptCapture = runWithCont captureGhc
 
 runWithCont ::
-    (FilePath -> FilePath -> FilePath -> IO a) -> FilePath -> ScriptFile -> IO a
-runWithCont cont scriptPath sf = do
+    (FilePath -> FilePath -> FilePath -> IO a) ->
+    RunOptions ->
+    FilePath ->
+    ScriptFile ->
+    IO a
+runWithCont cont opts scriptPath sf = do
     scriptAbsPath <- makeAbsolute scriptPath
     userCwd <- getCurrentDirectory
-    projectDir <- ensureProject scriptAbsPath (scriptMeta sf) (scriptLines sf)
+    warnUnknownKeys (metaUnknownKeys (scriptMeta sf))
+    projectDir <- ensureProject opts scriptAbsPath (scriptMeta sf) (scriptLines sf)
     cont userCwd projectDir (projectDir </> "script.ghci")
 
+-- | Warn (once each) about unrecognised @-- cabal:@ directive keys.
+warnUnknownKeys :: [T.Text] -> IO ()
+warnUnknownKeys keys =
+    mapM_
+        (\k -> TIO.hPutStrLn stderr ("scripths: warning: unknown '-- cabal:' key: " <> k))
+        (nub keys)
+
 deriveProjectName :: FilePath -> String
 deriveProjectName path =
     let sanitized = map (\c -> if isAlphaNum c then c else '-') path
      in dropWhile (== '-') sanitized
 
-ensureProject :: FilePath -> CabalMeta -> [Line] -> IO FilePath
-ensureProject scriptAbsPath meta scriptCode = do
+ensureProject :: RunOptions -> FilePath -> CabalMeta -> [Line] -> IO FilePath
+ensureProject opts scriptAbsPath meta scriptCode = do
     home <- getHomeDirectory
     let name = deriveProjectName scriptAbsPath
         projectDir = home </> ".scripths" </> name
     createDirectoryIfMissing True projectDir
-    let cabalProjectPath = projectDir </> "cabal.project"
-    cabalProjectExists <- doesFileExist cabalProjectPath
-    unless cabalProjectExists $ writeFile cabalProjectPath "packages: .\n"
+    enclosing <-
+        if roEnclosingProject opts
+            then enclosingProjectFor scriptAbsPath (metaDeps meta)
+            else pure []
+    let localPkgs = nub (roPackages opts ++ enclosing)
+    writeManagedCabalProject
+        (projectDir </> "cabal.project")
+        (renderCabalProject localPkgs (metaSourceRepos meta))
     let mainHsPath = projectDir </> "Main.hs"
     mainHsExists <- doesFileExist mainHsPath
     unless mainHsExists $ writeFile mainHsPath "main :: IO ()\nmain = pure ()\n"
     writeFile (projectDir </> (name ++ ".cabal")) (renderCabalFile name meta)
     TIO.writeFile (projectDir </> "script.ghci") (toGhciScript scriptCode)
     pure projectDir
+
+{- | First line of a scripths-generated @cabal.project@. We regenerate only
+files that carry it (or the legacy @packages: .@ default), never hand-edited ones.
+-}
+managedSentinel :: T.Text
+managedSentinel =
+    "-- managed by scripths (regenerated each run; remove this line to hand-edit)"
+
+{- | Render a @cabal.project@ from local package dirs + git source-repo pins.
+Always includes @.@ (the synthetic script package).
+-}
+renderCabalProject :: [FilePath] -> [SourceRepoPin] -> T.Text
+renderCabalProject localPkgs repos =
+    T.unlines $
+        [managedSentinel, "packages: ."]
+            ++ map (\p -> "          " <> T.pack p) localPkgs
+            ++ concatMap renderRepo repos
+  where
+    renderRepo r =
+        [ "source-repository-package"
+        , "    type: git"
+        , "    location: " <> srpLocation r
+        , "    tag: " <> srpRef r
+        ]
+            ++ maybe [] (\s -> ["    subdir: " <> s]) (srpSubdir r)
+
+{- | Write @cabal.project@ only if absent, scripths-managed (carries the
+sentinel), or the legacy @packages: .@ default — never clobber a hand-edited one.
+-}
+writeManagedCabalProject :: FilePath -> T.Text -> IO ()
+writeManagedCabalProject path content = do
+    exists <- doesFileExist path
+    managed <-
+        if not exists
+            then pure True
+            else do
+                existing <- TIO.readFile path
+                pure (managedSentinel `T.isPrefixOf` existing || existing == "packages: .\n")
+    when managed $ TIO.writeFile path content
+
+{- | If, enclosing the script, there is a cabal project whose package name is
+named by a @build-depends@, return that project root (so docs build against the
+local working tree). Empty otherwise.
+-}
+enclosingProjectFor :: FilePath -> [T.Text] -> IO [FilePath]
+enclosingProjectFor scriptAbsPath deps = go (takeDirectory scriptAbsPath)
+  where
+    names = map depPkgName deps
+    go dir = do
+        cabals <- findCabalFiles dir
+        mName <- case cabals of
+            (cf : _) -> readCabalPackageName (dir </> cf)
+            [] -> pure Nothing
+        case mName of
+            Just nm | nm `elem` names -> pure [dir]
+            _ ->
+                let parent = takeDirectory dir
+                 in if parent == dir then pure [] else go parent
+
+-- | Package name from a build-depends item (drops version bounds and @:sublib@).
+depPkgName :: T.Text -> T.Text
+depPkgName = T.takeWhile (\c -> isAlphaNum c || c == '-' || c == '_') . T.stripStart
+
+findCabalFiles :: FilePath -> IO [FilePath]
+findCabalFiles dir = do
+    entries <- listDirectory dir
+    -- Keep only regular files: a *directory* named @.cabal@ (cabal's home dir,
+    -- which an upward walk reaches) also matches @takeExtension == ".cabal"@.
+    filterM
+        (doesFileExist . (dir </>))
+        [e | e <- entries, takeExtension e == ".cabal"]
+
+readCabalPackageName :: FilePath -> IO (Maybe T.Text)
+readCabalPackageName path = do
+    contents <- TIO.readFile path
+    pure $
+        listToMaybe
+            [ T.strip (T.drop 1 (T.dropWhile (/= ':') ln))
+            | ln <- T.lines contents
+            , "name:" `T.isPrefixOf` T.toLower (T.stripStart ln)
+            ]
 
 renderCabalFile :: String -> CabalMeta -> String
 renderCabalFile name CabalMeta{metaDeps, metaExts, metaGhcOptions} =
diff --git a/test/Test/Notebook.hs b/test/Test/Notebook.hs
--- a/test/Test/Notebook.hs
+++ b/test/Test/Notebook.hs
@@ -18,6 +18,7 @@
  )
 
 import ScriptHs.Parser (CabalMeta (metaDeps), Line (..))
+import ScriptHs.Run (defaultRunOptions)
 
 notebookTests :: TestTree
 notebookTests =
@@ -191,7 +192,7 @@
             "processNotebook"
             [ testCase "no code blocks => returns input unchanged" $ do
                 let input = T.unlines ["# Title", "", "Just prose.", ""]
-                out <- processNotebook "" input
+                out <- processNotebook defaultRunOptions "" input
                 out @?= input
             , testCase "non-haskell code blocks only => returns input unchanged" $ do
                 let input =
@@ -204,7 +205,7 @@
                             , ""
                             , "more prose"
                             ]
-                out <- processNotebook "" input
+                out <- processNotebook defaultRunOptions "" input
                 out @?= input
             ]
         ]
diff --git a/test/Test/Parser.hs b/test/Test/Parser.hs
--- a/test/Test/Parser.hs
+++ b/test/Test/Parser.hs
@@ -10,9 +10,10 @@
 
 import qualified Data.Text as T
 import ScriptHs.Parser (
-    CabalMeta (metaDeps, metaExts, metaGhcOptions),
+    CabalMeta (metaDeps, metaExts, metaGhcOptions, metaSourceRepos, metaUnknownKeys),
     Line (..),
     ScriptFile (scriptLines, scriptMeta),
+    SourceRepoPin (..),
     parseScript,
  )
 
@@ -99,10 +100,17 @@
                 let sf = parseScript input
                 (metaDeps . scriptMeta) sf @?= ["base", "text", "containers"]
                 (metaExts . scriptMeta) sf @?= ["GADTs"]
-            , testCase "unknown cabal key is ignored" $ do
+            , testCase "unknown cabal key is recorded (and warned), not a dep" $ do
                 let sf = parseScript "-- cabal: foo: bar, baz\n"
                 (metaDeps . scriptMeta) sf @?= []
                 (metaExts . scriptMeta) sf @?= []
+                (metaUnknownKeys . scriptMeta) sf @?= ["foo"]
+            , testCase "source-repository-package directive parses a git pin" $ do
+                let sf =
+                        parseScript
+                            "-- cabal: source-repository-package: https://x/repo abc123 sub\n"
+                (metaSourceRepos . scriptMeta) sf
+                    @?= [SourceRepoPin "https://x/repo" "abc123" (Just "sub")]
             ]
         , testGroup
             "Multi-line scripts"
diff --git a/test/Test/Render.hs b/test/Test/Render.hs
--- a/test/Test/Render.hs
+++ b/test/Test/Render.hs
@@ -407,7 +407,7 @@
             assertBool "has main = do" (T.isInfixOf "main = do" txt)
             assertBool "indents print 1" (T.isInfixOf "    print 1" txt)
         , testCase "cabal-script header includes base + Wno-unused-imports" $ do
-            let hdr = renderCabalScriptHeader (CabalMeta ["dataframe", "text"] [] [])
+            let hdr = renderCabalScriptHeader (CabalMeta ["dataframe", "text"] [] [] [] [])
             assertBool "opens block" (T.isInfixOf "{- cabal:" hdr)
             assertBool
                 "base + deps"
diff --git a/test/Test/Run.hs b/test/Test/Run.hs
--- a/test/Test/Run.hs
+++ b/test/Test/Run.hs
@@ -1,10 +1,12 @@
 module Test.Run (runTests) where
 
 import Data.List (isPrefixOf)
+import qualified Data.Text as T
 import Test.Tasty (TestTree, testGroup)
 import Test.Tasty.HUnit (assertBool, testCase, (@?=))
 
-import ScriptHs.Run (cabalArgs)
+import ScriptHs.Parser (SourceRepoPin (..))
+import ScriptHs.Run (cabalArgs, renderCabalProject)
 
 runTests :: TestTree
 runTests =
@@ -24,5 +26,23 @@
                 let args = cabalArgs "/proj" "/proj/script.ghci"
                 assertBool "--project-dir present" $
                     any ("--project-dir=" `isPrefixOf`) args
+            ]
+        , testGroup
+            "renderCabalProject"
+            [ testCase "base case has the managed sentinel and packages: ." $ do
+                let txt = renderCabalProject [] []
+                assertBool "sentinel" (T.isInfixOf "managed by scripths" txt)
+                assertBool "packages ." (T.isInfixOf "packages: ." txt)
+            , testCase "local package dirs appear as packages entries" $ do
+                let txt = renderCabalProject ["/abs/granite"] []
+                assertBool "abs path" (T.isInfixOf "/abs/granite" txt)
+            , testCase "git pin renders a source-repository-package stanza" $ do
+                let pin = SourceRepoPin "https://x/repo" "abc123" (Just "sub")
+                    txt = renderCabalProject [] [pin]
+                assertBool "stanza" (T.isInfixOf "source-repository-package" txt)
+                assertBool "type" (T.isInfixOf "type: git" txt)
+                assertBool "location" (T.isInfixOf "location: https://x/repo" txt)
+                assertBool "tag" (T.isInfixOf "tag: abc123" txt)
+                assertBool "subdir" (T.isInfixOf "subdir: sub" txt)
             ]
         ]
