packages feed

seihou-cli-0.7.0.0: src/Seihou/CLI/BlueprintMigration.hs

-- | Pure selection/rendering and callback-driven execution for ordered
-- agent-guided blueprint migrations.
module Seihou.CLI.BlueprintMigration
  ( BlueprintMigrationLaunchFailure (..),
    BlueprintMigrationLaunchResult (..),
    BlueprintMigrationRunResult (..),
    renderBlueprintMigrationInstruction,
    renderBlueprintMigrationSystemPrompt,
    formatBlueprintMigrationDebugOutput,
    formatMigrationStepLabel,
    pendingBlueprintMigrations,
    parseNotApplicableSignal,
    unstatedNotApplicableReason,
    runBlueprintMigrationsWith,

    -- * Inferring the version window
    VersionSource (..),
    ResolvedWindow (..),
    WindowResolutionError (..),
    VersionProbeResult (..),
    highestMigratedVersion,
    resolveMigrationWindow,
    readVersionProbeOutput,
    runVersionProbe,
    formatResolvedWindow,
    formatProbeFailure,
    formatWindowResolutionError,
  )
where

import Data.Char (isAlphaNum, isSpace)
import Data.Generics.Labels ()
import Data.List (sortOn)
import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
import Data.Ord (Down (..))
import Data.Text qualified as T
import Data.Time.Format (defaultTimeLocale, formatTime)
import Seihou.CLI.AgentLaunch
  ( AgentContext (..),
    formatAvailableModules,
    formatLocalModules,
    formatManifestState,
    formatModuleDhallState,
    formatSeihouProjectState,
    substitute,
  )
import Seihou.CLI.BlueprintExecution
  ( PreparedBlueprintExecution (..),
    renderBlueprintText,
  )
import Seihou.Core.ArtifactIdentity (sameArtifactIdentity)
import Seihou.Core.Migration
  ( BlueprintMigration (..),
    BlueprintMigrationPlan (..),
    BlueprintMigrationStep (..),
    EntailmentSite (..),
  )
import Seihou.Core.Types
  ( AppliedBlueprintMigration (..),
    ArtifactOrigin (..),
    Blueprint (..),
    MigrationOutcome (..),
    ModuleName (..),
    ResolvedVar,
    VarName,
  )
import Seihou.Core.Version (Version, parseVersion, renderVersion)
import Seihou.Effect.Process (Process, runProcess)
import Seihou.Prelude
import System.Exit (ExitCode (..))

-- | Provider failures retain either a real interactive process exit or API
-- error text rather than collapsing both paths into an artificial exit code.
data BlueprintMigrationLaunchFailure
  = BlueprintMigrationProcessFailure ExitCode
  | BlueprintMigrationProviderFailure Text
  deriving stock (Eq, Show)

-- | What one edge's provider interaction produced, when it produced anything
-- at all. A launch that never returned is a 'BlueprintMigrationLaunchFailure'
-- instead; these two constructors are both non-failures, and the chain
-- continues past either of them.
data BlueprintMigrationLaunchResult
  = BlueprintMigrationSessionReturned
  | BlueprintMigrationSessionNotApplicable !Text
  deriving stock (Eq, Show)

-- | Terminal outcome for one pending migration chain.
--
-- 'BlueprintMigrationComplete' carries each edge together with what it
-- produced, so the caller can report how many edges did real work and how many
-- reported themselves inapplicable without re-reading the manifest.
data BlueprintMigrationRunResult
  = BlueprintMigrationNoWork
  | BlueprintMigrationComplete [(BlueprintMigrationStep, MigrationOutcome)]
  | BlueprintMigrationLaunchFailed BlueprintMigrationStep BlueprintMigrationLaunchFailure
  | BlueprintMigrationRecordFailed BlueprintMigrationStep Text
  deriving stock (Eq, Show)

-- | Render the edge-specific instruction with the same resolved variables as
-- the blueprint's shared prompt.
renderBlueprintMigrationInstruction ::
  Map VarName ResolvedVar ->
  BlueprintMigration ->
  Text
renderBlueprintMigrationInstruction resolved migration =
  renderBlueprintText resolved (migration ^. #prompt)

-- | Fill the migration-specific embedded template. The template itself stays
-- in the executable target because @Data.FileEmbed@ traps it there; accepting
-- it as an argument keeps all rendering policy pure and unit-testable here.
-- The not-applicable signal path is passed in for the same reason: the caller
-- knows the project root, and this stays a function of its arguments.
--
-- @prepared@ must be the execution context of the step's /owning/ blueprint,
-- not of the blueprint named on the command line. Under entailment those
-- differ, and the agent is told the owner's identity and handed the owner's
-- reference files, because it is doing the owner's migration.
renderBlueprintMigrationSystemPrompt ::
  Text ->
  -- | absolute path the agent writes to when this edge does not apply
  FilePath ->
  AgentContext ->
  -- | the /owning/ blueprint's prepared execution
  PreparedBlueprintExecution ->
  Int ->
  Int ->
  BlueprintMigrationStep ->
  Text
renderBlueprintMigrationSystemPrompt template signalPath ctx prepared position total step =
  let blueprint = (prepared ^. #blueprint)
      migration = (step ^. #edge)
      renderedInstruction =
        renderBlueprintMigrationInstruction (prepared ^. #resolvedVariables) migration
   in substitute
        [ ("cwd", ctx ^. #cwd),
          ("seihou_project_state", formatSeihouProjectState ctx),
          ("manifest_state", formatManifestState ctx),
          ("module_dhall_state", formatModuleDhallState ctx),
          ("local_modules", formatLocalModules ctx),
          ("available_modules", formatAvailableModules ctx),
          ("blueprint_name", blueprint ^. #name . #unModuleName),
          ("blueprint_version", fromMaybe "(unspecified)" (blueprint ^. #version)),
          ("blueprint_description", fromMaybe "(no description)" (blueprint ^. #description)),
          ("migration_from", migration ^. #from),
          ("migration_to", migration ^. #to),
          ("migration_position", T.pack (show position)),
          ("migration_total", T.pack (show total)),
          ("migration_entailed_by", formatEntailedBy step),
          ("reference_files", prepared ^. #referenceFiles),
          ("reference_files_dir", prepared ^. #referenceFilesAccess),
          ("shared_prompt", prepared ^. #sharedPrompt),
          ("migration_prompt", renderedInstruction),
          ("not_applicable_signal_path", T.pack signalPath)
        ]
        template

-- | Clearly delimit every pending prompt for parent debug mode. This pure
-- function cannot launch a provider or receive a recorder, which makes the
-- migration debug path structurally read-only.
--
-- Each header names the step's owning blueprint, because a chain may span
-- several: a reader inspecting a cohort migration has no other way to tell
-- which blueprint's prompt they are looking at.
formatBlueprintMigrationDebugOutput ::
  (Int -> Int -> BlueprintMigrationStep -> Text) ->
  [BlueprintMigrationStep] ->
  Text
formatBlueprintMigrationDebugOutput render steps =
  T.intercalate
    "\n\n"
    [ T.unlines
        [ "===== ["
            <> T.pack (show position)
            <> "/"
            <> T.pack (show total)
            <> "] "
            <> formatMigrationStepLabel step
            <> " =====",
          render position total step
        ]
    | (position, step) <- zip [1 ..] steps
    ]
  where
    total = length steps

-- | Name one step the way every user-facing surface names it: the owning
-- blueprint, its edge window, and — when the step was reached through
-- entailment rather than named on the command line — what pulled it in.
--
-- One definition rather than three, because the launch announcement, the
-- debug headers, and the failure messages must agree; a chain that spans
-- blueprints is confusing enough without three spellings of the same step.
formatMigrationStepLabel :: BlueprintMigrationStep -> Text
formatMigrationStepLabel step =
  step ^. #owner
    <> " "
    <> step ^. #edge . #from
    <> " -> "
    <> step ^. #edge . #to
    <> maybe "" (\site -> " (entailed by " <> renderSite site <> ")") (step ^. #entailedBy)

-- | The sentence the framing prompt uses to explain to an agent why it is
-- migrating a library the user did not name. Empty for a directly selected
-- edge, which needs no explanation.
formatEntailedBy :: BlueprintMigrationStep -> Text
formatEntailedBy step = case step ^. #entailedBy of
  Nothing -> ""
  Just site ->
    "This edge was not requested directly. It is required by "
      <> renderSite site
      <> ", which the user is migrating."

renderSite :: EntailmentSite -> Text
renderSite site =
  site ^. #blueprint <> " " <> site ^. #from <> " -> " <> site ^. #to

-- | Remove applied exact-edge receipts while retaining planner order.
--
-- Exact-edge identity is the origin and name of the blueprint that owns the
-- edge together with its @from@ and @to@ versions. Artifact versions and
-- timestamps are intentionally not part of the completion key: an edge is the
-- same edge regardless of which release of the blueprint declared it. Origin
-- is part of it, because two blueprints published by different repositories
-- that share a name and an edge window are not the same edge, and dropping a
-- second repository's edge because the first one's is recorded would be a
-- silent skip of work that never ran.
--
-- The identity used for a step is the /owning/ blueprint's, resolved through
-- @lookupOwner@, not the identity of the blueprint the user invoked. Under
-- entailment a single plan contains steps owned by several blueprints, and
-- this is the mechanism that makes a shared cohort edge the same edge from
-- either entry point: a project that crossed kiroku's edge by running
-- @keiro-upgrade@ has a receipt under @kiroku-upgrade@'s identity, so running
-- @kiroku-upgrade@ directly finds that receipt and crosses nothing twice.
--
-- @lookupOwner@ returning 'Nothing' cannot happen in production: cohort
-- discovery resolves every owner before a plan reaches this function. It is
-- treated as "not previously applied" rather than as a crash, because the
-- honest failure for an unresolvable owner is the discovery error the caller
-- already raises, not a receipt lookup that silently claims completion.
--
-- The receipt's outcome is also part of the decision, though not of the
-- edge's identity. Only a 'MigrationApplied' receipt suppresses its edge. A
-- 'MigrationNotApplicable' one records that the edge was evaluated and found
-- inapplicable to this project, which says nothing about whether it applies
-- now — the precondition it reported unmet may since have been met, and that
-- is the ordinary case, because satisfying it is usually what the edge told
-- the user to do.
--
-- Receipts written before origins were recorded decode as
-- @'LocalOrigin' name@, which matches other such receipts and matches nothing
-- installed from a git URL. A project upgrading across that change therefore
-- sees its previously-recorded edges become pending once; that is honest,
-- because seihou cannot prove the recorded edge and the planned one came from
-- the same repository.
pendingBlueprintMigrations ::
  Bool ->
  -- | the recorded identity of a step's owning blueprint, by name
  (Text -> Maybe (ModuleName, ArtifactOrigin)) ->
  [AppliedBlueprintMigration] ->
  BlueprintMigrationPlan ->
  [BlueprintMigrationStep]
pendingBlueprintMigrations rerun lookupOwner receipts plan
  | rerun = plan ^. #steps
  | otherwise = filter (not . alreadyApplied) (plan ^. #steps)
  where
    alreadyApplied step = case lookupOwner (step ^. #owner) of
      Nothing -> False
      Just (ownerName, ownerOrigin) ->
        any
          ( \receipt ->
              receipt ^. #outcome == MigrationApplied
                && sameArtifactIdentity (receipt ^. #origin) ownerOrigin
                && receipt ^. #name == ownerName
                && receipt ^. #fromVersion == step ^. #edge . #from
                && receipt ^. #toVersion == step ^. #edge . #to
          )
          receipts

-- | Launch and record one pending edge at a time. A receipt is requested only
-- after its launch returns, and either callback failure stops the chain before
-- the next launch.
--
-- An edge that reports itself not applicable is not a failure and does not
-- stop the chain: its receipt is written with that outcome and the next edge
-- launches, exactly as after an applied one.
runBlueprintMigrationsWith ::
  (Int -> Int -> BlueprintMigrationStep -> IO (Either BlueprintMigrationLaunchFailure BlueprintMigrationLaunchResult)) ->
  (BlueprintMigrationStep -> MigrationOutcome -> IO (Either Text ())) ->
  [BlueprintMigrationStep] ->
  IO BlueprintMigrationRunResult
runBlueprintMigrationsWith _launch _record [] = pure BlueprintMigrationNoWork
runBlueprintMigrationsWith launch record steps =
  go [] (zip [1 ..] steps)
  where
    total = length steps

    go completed [] = pure (BlueprintMigrationComplete (reverse completed))
    go completed ((position, step) : rest) = do
      launchResult <- launch position total step
      case launchResult of
        Left failure -> pure (BlueprintMigrationLaunchFailed step failure)
        Right sessionResult -> do
          let outcome = case sessionResult of
                BlueprintMigrationSessionReturned -> MigrationApplied
                BlueprintMigrationSessionNotApplicable reason -> MigrationNotApplicable reason
          recordResult <- record step outcome
          case recordResult of
            Left err -> pure (BlueprintMigrationRecordFailed step err)
            Right () -> go ((step, outcome) : completed) rest

-- | Extract a not-applicable signal from an API provider's assistant text.
--
-- Recognises a line of the form @SEIHOU: not-applicable \<reason\>@ among the
-- last few non-empty lines, tolerating the surrounding whitespace, backticks
-- and emphasis a model is liable to add. The marker itself is matched
-- strictly: the line must begin with it, so prose that merely discusses
-- applicability is not a signal. A false positive here silently skips real
-- work, which is worse than missing a signal an agent could have written to
-- the signal file instead.
parseNotApplicableSignal :: Text -> Maybe Text
parseNotApplicableSignal assistantText =
  listToMaybe (mapMaybe signalOnLine candidateLines)
  where
    candidateLines =
      take signalScanDepth $
        reverse $
          filter (not . T.null) $
            map T.strip (T.lines assistantText)

    signalOnLine line = do
      afterMarker <- T.stripPrefix "SEIHOU:" (stripDecoration line)
      afterToken <- T.stripPrefix "not-applicable" (stripDecoration afterMarker)
      -- The token must end a word: 'not-applicable-ish' is not the marker.
      if maybe False continuesTheToken (fst <$> T.uncons afterToken)
        then Nothing
        else Just (readReason afterToken)

    continuesTheToken c = isAlphaNum c || c == '-'

    readReason =
      orPlaceholder
        . stripDecoration
        . T.dropWhile (\c -> isSpace c || c `elem` (":-–—" :: String))
        . stripDecoration

    orPlaceholder reason
      | T.null reason = unstatedNotApplicableReason
      | otherwise = reason

    stripDecoration = T.dropAround (\c -> isSpace c || c `elem` ("*_`" :: String))

-- | How many trailing non-empty lines of an assistant reply to search for the
-- marker. A model that signals usually does so last, but often follows with a
-- closing sentence or two.
signalScanDepth :: Int
signalScanDepth = 5

-- | What to record when an edge signals inapplicability without saying why.
-- The signal is a deliberate act either way, so it is honoured; the reason is
-- what suffers.
unstatedNotApplicableReason :: Text
unstatedNotApplicableReason = "(no reason given)"

-- ---------------------------------------------------------------------------
-- Inferring the version window
-- ---------------------------------------------------------------------------

-- | Where one end of the migration version window came from. Carried so the
-- command can tell the user what it inferred and why, which matters more here
-- than usual: an inferred window silently off by one release would run the
-- wrong edges against their source.
data VersionSource
  = VersionFromFlag
  | -- | The blueprint's declared probe command, which printed this version.
    VersionFromProbe !Text
  | -- | The receipt this end was read from. The whole record is carried
    -- rather than only its edge window, because the reported line names the
    -- blueprint and the date the edge was applied, and a user checking an
    -- inferred start needs to recognise the run it came from.
    VersionFromReceipt !AppliedBlueprintMigration
  deriving stock (Eq, Show, Generic)

-- | Both ends of the window, each with the reason it holds that value.
data ResolvedWindow = ResolvedWindow
  { fromVersion :: !Version,
    fromSource :: !VersionSource,
    toVersion :: !Version,
    toSource :: !VersionSource
  }
  deriving stock (Eq, Show, Generic)

-- | Why a window could not be resolved. Both cases are recoverable by passing
-- the flag the message names, so neither is reported as a defect.
data WindowResolutionError
  = -- | No @--to@ was given, and no probe supplied one.
    NoTargetVersion
  | -- | No @--from@ was given, and this project has no applied receipt for
    -- this blueprint to start from.
    NoStartVersion
  deriving stock (Eq, Show, Generic)

-- | What running a blueprint's declared version probe produced.
--
-- Only 'ProbeVersion' contributes to the window. The other two are reported to
-- the user and then treated as "no probe result": a probe is the blueprint
-- author's convenience, and a broken one must degrade to requiring @--to@
-- rather than failing a command the user can still complete by hand.
data VersionProbeResult
  = ProbeVersion !Version
  | -- | Exit code and captured stderr.
    ProbeExitedNonZero !Int !Text
  | -- | The probe succeeded but printed something that is not a dotted
    -- numeric version. Carries the raw stdout.
    ProbeOutputUnparseable !Text
  deriving stock (Eq, Show, Generic)

-- | The highest version this project has already migrated this blueprint to,
-- with the receipt that says so.
--
-- Only receipts belonging to this blueprint identity are considered — name and
-- origin both, per docs\/adr\/0002-artifact-identity-is-origin-url-plus-name.md
-- and compared with 'sameArtifactIdentity' rather than structural equality,
-- because a same-named blueprint from another repository records a different
-- project history and two spellings of one git URL record the same one.
--
-- The identity to pass is that of the blueprint whose /own/ edges are being
-- windowed. For @seihou agent migrate@ that is the invoked blueprint, because
-- the window is expressed in the invoked library's version space; an entailed
-- blueprint's steps are windowed by the edge that entails them, not by a
-- window of their own.
--
-- Two exclusions are deliberate:
--
--   * A receipt whose @toVersion@ does not parse is skipped rather than
--     failing the command. Receipts are data written by earlier runs, and one
--     malformed entry must not make the command unusable.
--
--   * A 'MigrationNotApplicable' receipt does not count. It records that
--     seihou considered an edge and this project did not need it, which says
--     nothing about how far the source has been carried. Counting it would
--     start the window above edges that were never applied and skip them
--     permanently.
highestMigratedVersion ::
  ArtifactOrigin ->
  ModuleName ->
  [AppliedBlueprintMigration] ->
  Maybe (Version, AppliedBlueprintMigration)
highestMigratedVersion origin name receipts =
  listToMaybe (sortOn (Down . fst) (mapMaybe reached receipts))
  where
    reached receipt
      | receipt ^. #outcome /= MigrationApplied = Nothing
      | not (sameArtifactIdentity (receipt ^. #origin) origin) = Nothing
      | receipt ^. #name /= name = Nothing
      | otherwise = (,receipt) <$> parseVersion (receipt ^. #toVersion)

-- | Decide each end of the window from what the user supplied and what seihou
-- could infer.
--
-- Precedence is per end and independent: an explicit flag always wins, and
-- either end may be inferred while the other is typed.
--
-- The two ends deliberately draw on different sources. @--to@ takes the probe,
-- which reads how far the /dependency/ has been bumped in this project;
-- @--from@ takes the receipt ledger, which records how far the /source/ has
-- been migrated. Swapping them would break the workflow this exists for: the
-- normal sequence is to bump the dependency and then migrate the source up to
-- it, so at the moment the command runs the lockfile already names the target.
--
-- Running the probe is the caller's job, and its result arrives here already
-- parsed. That keeps this pure, and lets the caller skip the subprocess
-- entirely when @--to@ was given.
resolveMigrationWindow ::
  -- | @--from@, already parsed
  Maybe Version ->
  -- | @--to@, already parsed
  Maybe Version ->
  -- | the probe's version and the command that produced it
  Maybe (Version, Text) ->
  -- | the highest applied receipt, from 'highestMigratedVersion'
  Maybe (Version, AppliedBlueprintMigration) ->
  Either WindowResolutionError ResolvedWindow
resolveMigrationWindow fromFlag toFlag probed recorded = do
  (target, targetSource) <- case (toFlag, probed) of
    (Just version, _) -> Right (version, VersionFromFlag)
    (Nothing, Just (version, command)) -> Right (version, VersionFromProbe command)
    (Nothing, Nothing) -> Left NoTargetVersion
  (start, startSource) <- case (fromFlag, recorded) of
    (Just version, _) -> Right (version, VersionFromFlag)
    (Nothing, Just (version, receipt)) -> Right (version, VersionFromReceipt receipt)
    (Nothing, Nothing) -> Left NoStartVersion
  pure
    ResolvedWindow
      { fromVersion = start,
        fromSource = startSource,
        toVersion = target,
        toSource = targetSource
      }

-- | Read a probe's captured stdout as a version.
--
-- The rule is the /last non-empty line/, trimmed, rather than the whole of
-- stdout: a probe like @nix eval@ prints progress before its answer, and
-- requiring authors to silence every tool's chatter would make probes
-- fragile. Authors need to know this rule, so it is documented in
-- docs\/user\/blueprints.md as well as here.
readVersionProbeOutput :: Text -> VersionProbeResult
readVersionProbeOutput raw =
  case lastNonEmptyLine of
    Just line | Just version <- parseVersion line -> ProbeVersion version
    _ -> ProbeOutputUnparseable raw
  where
    lastNonEmptyLine =
      listToMaybe (reverse (filter (not . T.null) (map T.strip (T.lines raw))))

-- | Run a blueprint's declared version probe in the project directory.
--
-- Executed through @sh -c@, exactly as a module's @RunCommand@ operation and a
-- command-derived variable are, so an author writes the same kind of shell
-- string everywhere. This function has no timeout of its own; the caller
-- bounds it, because a bound belongs where the real clock is.
runVersionProbe ::
  (Process :> es) =>
  -- | the declared command
  Text ->
  -- | the project directory to run it in
  FilePath ->
  Eff es VersionProbeResult
runVersionProbe command projectRoot = do
  (exitCode, stdoutText, stderrText) <- runProcess "sh" ["-c", command] (Just projectRoot)
  pure $ case exitCode of
    ExitSuccess -> readVersionProbeOutput stdoutText
    ExitFailure code -> ProbeExitedNonZero code stderrText

-- | Report the resolved window and where each end came from.
--
-- Returns no lines at all when the user typed both flags and did not ask for
-- verbose output: they already know what they typed, and existing invocations
-- should keep printing exactly what they printed before. An /inferred/ end is
-- always reported, verbose or not — a window silently off by one release runs
-- the wrong agent sessions against the user's source, which is worth two lines.
formatResolvedWindow :: Bool -> ResolvedWindow -> [Text]
formatResolvedWindow verbose window
  | null provenance = []
  | otherwise = header : provenance
  where
    header =
      "Version window: "
        <> renderVersion (window ^. #fromVersion)
        <> " -> "
        <> renderVersion (window ^. #toVersion)

    provenance =
      end "--from" (window ^. #fromVersion) (window ^. #fromSource)
        <> end "--to  " (window ^. #toVersion) (window ^. #toSource)

    end flag version source =
      [ "  " <> flag <> " " <> renderVersion version <> "  " <> renderSource source
      | verbose || source /= VersionFromFlag
      ]

    renderSource = \case
      VersionFromFlag -> "[flag]"
      VersionFromProbe command -> "[probe: " <> command <> "]"
      VersionFromReceipt receipt ->
        "[receipt: "
          <> receipt ^. #name . #unModuleName
          <> " "
          <> receipt ^. #fromVersion
          <> " -> "
          <> receipt ^. #toVersion
          <> ", applied "
          <> T.pack (formatTime defaultTimeLocale "%Y-%m-%d" (receipt ^. #appliedAt))
          <> "]"

-- | Explain a probe that did not produce a version.
--
-- The blueprint's author wrote the command and the consumer is the one holding
-- the failure, so the message shows enough to forward upstream — and says
-- plainly that the run can continue with an explicit flag.
formatProbeFailure :: Text -> VersionProbeResult -> Maybe Text
formatProbeFailure command = \case
  ProbeVersion _ -> Nothing
  ProbeExitedNonZero code stderrText ->
    Just $
      "The blueprint's version probe failed, so --to could not be inferred.\n"
        <> "  probe:  "
        <> command
        <> "\n  exit:   "
        <> T.pack (show code)
        <> diagnostic "stderr" stderrText
  ProbeOutputUnparseable raw ->
    Just $
      "The blueprint's version probe printed no dotted numeric version, so --to could not be inferred.\n"
        <> "  probe:  "
        <> command
        <> diagnostic "output" raw
  where
    -- Both labels are six characters, so one space after the colon lines
    -- their values up under `probe:` and `exit:` above them.
    diagnostic label text
      | T.null trimmed = ""
      | otherwise = "\n  " <> label <> ": " <> T.replace "\n" "\n          " trimmed
      where
        trimmed = T.strip text

-- | Turn an unresolvable window into the sentence the user has to act on.
--
-- The missing-start case is the first-run case and will be much the commoner
-- of the two, so it explains rather than complains: seihou has no record of
-- this project's migration history, which is a fact about the project and not
-- a mistake by the person typing.
formatWindowResolutionError :: ModuleName -> WindowResolutionError -> Text
formatWindowResolutionError blueprintName = \case
  NoTargetVersion ->
    "Cannot determine the target version for '"
      <> name
      <> "'.\n\n"
      <> "  Pass --to VERSION, or ask the blueprint's author to declare a versionProbe\n"
      <> "  so seihou can read the version this project depends on."
  NoStartVersion ->
    "Cannot determine the starting version for '"
      <> name
      <> "'.\n\n"
      <> "  This project has no recorded migration for that blueprint, so seihou does\n"
      <> "  not know how far its source has already been migrated.\n\n"
      <> "  Pass --from VERSION."
  where
    name = blueprintName ^. #unModuleName