packages feed

seihou-cli 0.4.0.0 → 0.5.0.0

raw patch · 57 files changed

+8006/−417 lines, 57 filesdep ~baikaidep ~baikai-claudedep ~baikai-kit

Dependency ranges changed: baikai, baikai-claude, baikai-kit, baikai-openai, seihou-core

Files

+ data/blueprint-migration-prompt.md view
@@ -0,0 +1,76 @@+You are running one ordered Seihou blueprint migration for a library upgrade.+The blueprint author supplied shared library guidance plus instructions for this+exact version edge. Work only on the current edge; later edges run in separate+agent sessions after this one succeeds.++You may be running in an interactive local CLI with repository tools, or as a+one-shot API completion without tools. When tools are available, inspect the+project's actual use of the library, edit the repository directly, and run the+relevant validation. When tools are unavailable, return concrete guidance,+patch-style snippets, and validation commands the user can apply.+++## Current Environment++Working directory: {{cwd}}+{{seihou_project_state}}+{{manifest_state}}+{{module_dhall_state}}+{{local_modules}}+{{available_modules}}+++## Blueprint Identity++Name: {{blueprint_name}}+Version: {{blueprint_version}}+Description: {{blueprint_description}}+++## Migration Edge++Step {{migration_position}} of {{migration_total}}+From library version: {{migration_from}}+To library version: {{migration_to}}+++## Reference Files++The blueprint declares these shared library-upgrade references:++{{reference_files}}++{{reference_files_dir}}+++## Shared Blueprint Guidance++{{shared_prompt}}+++## Instructions for This Edge++{{migration_prompt}}+++## Workflow++1. Inspect how the project actually uses the library at the source version.+   Do not assume every API named in the guidance is present.+2. Read relevant mounted reference files when available. If they are not+   mounted, ask the user for anything essential and never claim to have read it.+3. Make only the changes needed for {{migration_from}} -> {{migration_to}}.+   Preserve unrelated user changes and do not pre-apply later migration edges.+4. Update source, configuration, and tests that are directly affected by this+   edge. Avoid broad cleanup unrelated to the upgrade.+5. Run the most relevant project validation available for this library change.+   Report checks you could not run and why.+6. Before exiting, summarize changed files, validation results, and any work+   that remains for the user or later migration steps.+++## Completion Boundary++Seihou records this exact edge after your provider interaction returns+successfully. That receipt is not package-manager verification. Do not report+the target version as installed unless you actually verified it in the project.
help/agent.md view
@@ -2,8 +2,8 @@  `seihou agent` renders Seihou-aware prompts and starts a configured provider. The agent commands are for AI-assisted-module authoring, bootstrapping, project setup, and running-agent-driven blueprints.+module authoring, bootstrapping, project setup, running agent-driven+blueprints, and applying ordered blueprint library migrations.  CLI providers open interactive local Claude Code or Codex sessions. API providers send one rendered prompt as a batch completion and print@@ -25,7 +25,7 @@    --model MODEL       Select a provider-specific model name or alias for this-      invocation.+      invocation. Run `seihou agent models` to list known choices.  Provider and model options may appear on the parent command or on the subcommand:@@ -81,6 +81,22 @@  SUBCOMMANDS +  seihou agent models [--provider PROVIDER]+      List the Anthropic and OpenAI models in Seihou's compiled Baikai+      catalog. Filtering by `anthropic` or `claude-cli` returns the same+      Claude-family models; filtering by `openai` or `codex-cli` returns+      the same OpenAI-family models. The filter may appear before or+      after `models`:++        seihou agent --provider claude-cli models+        seihou agent models --provider openai++      Listing models requires no provider credentials, network access,+      or agent configuration. The catalog is a discovery aid, not+      validation: provider-native aliases and custom model IDs remain+      accepted by `--model`. Do not pass `--model` to this listing+      command.+   seihou agent assist [PROMPT]       Render a prompt for creating or modifying Seihou modules. The       prompt includes current project context, available modules, and@@ -102,6 +118,17 @@       provider. A successful non-debug run records applied-blueprint       provenance in `.seihou/manifest.json`. +  seihou agent migrate BLUEPRINT --from VERSION --to VERSION [PROMPT]+      Run one agent session per in-window migration declared by the blueprint.+      Versions are explicit dotted numbers; gaps are allowed. Successful edges+      are recorded immediately so a later invocation resumes at the first+      unrecorded edge. `--rerun` ignores matching receipts. Migration mode does+      not apply baseModules and exposes neither --no-baseline nor --force.++      Parent --debug prints every pending migration prompt in order without+      contacting a provider or writing receipts. A receipt records provider+      completion, not package-manager verification.+   seihou prompt run PROMPT [USER-PROMPT] [--var KEY=VALUE] [--debug]       Resolve a reusable prompt, run command-derived variables, render       the prompt body, and send it to the configured provider. Prompts@@ -113,6 +140,7 @@   seihou agent --debug --provider codex-cli bootstrap --repo "inspect this prompt"   seihou agent --debug --provider openai setup "inspect this prompt"   seihou agent --debug run my-blueprint --var project.name=demo+  seihou agent --debug migrate my-library --from 1.0.0 --to 3.0.0  SEE ALSO 
help/blueprints.md view
@@ -3,7 +3,8 @@ A blueprint is an agent-driven runnable artifact. Modules and recipes use Seihou's deterministic execution engine to write files from declared steps. Blueprints instead package a prompt, optional baseline modules, variables,-and reference files for `seihou agent run`.+and reference files for `seihou agent run`. They can also package ordered+library-upgrade prompts for `seihou agent migrate`.  Use a blueprint when the desired output needs judgement, iteration, or project-specific design decisions that do not fit a fixed module template.@@ -39,6 +40,7 @@     files          Reference files under the blueprint's files/ directory.     allowedTools   Extra tools to pre-approve in addition to the base set.     tags           Optional discovery tags.+    migrations     Agent-guided library version edges (from, to, prompt).    Blueprints share the same name lookup namespace as modules and recipes.   If a directory contains more than one runnable definition, lookup prefers@@ -103,6 +105,37 @@   its workspace-write sandbox and on-request approval policy because it has no   equivalent per-tool allow-list option. +LIBRARY UPGRADE MIGRATIONS++  A blueprint migration describes one forward dotted-numeric version edge:++    migrations =+      [ S.BlueprintMigration::{+        , from = "1.0.0"+        , to = "2.0.0"+        , prompt = ./migrations/1-to-2.md as Text+        }+      , S.BlueprintMigration::{+        , from = "2.5.0"+        , to = "3.0.0"+        , prompt = ./migrations/2-5-to-3.md as Text+        }+      ]++  Run an installed library blueprint with explicit versions:++    seihou agent migrate my-library --from 1.0.0 --to 3.0.0++  Edges run in ascending `from` order and gaps are allowed. Duplicate starts+  are invalid; overlaps already passed by the cursor and edges overshooting the+  target are skipped. Each successful provider interaction writes an exact-edge+  receipt before the next session. Rerunning resumes; --rerun repeats matching+  receipts. Parent --debug prints pending prompts without launching or writing.++  Migration mode reuses variables, shared prompt, references, and allowed tools,+  but never applies baseModules. A receipt records agent completion, not proof+  that a package manager now reports the target version.+ COMMON COMMANDS    seihou new-blueprint api-service       Scaffold a blueprint@@ -110,6 +143,7 @@   seihou list                            List modules, recipes, blueprints, and prompts   seihou vars api-service                Show blueprint variables   seihou agent run api-service           Run the blueprint with an agent+  seihou agent migrate my-library --from 1.0.0 --to 3.0.0    `seihou run api-service` refuses when `api-service` resolves to a blueprint.   That command is reserved for deterministic modules and recipes; use@@ -126,6 +160,7 @@     - prompts reference declared variables     - baseModules resolve to modules or recipes     - declared reference files exist under files/+    - migrations have dotted forward versions, unique starts, and non-empty prompts    If validation fails, fix the reported check before publishing or running   the blueprint.
help/git-repository.md view
@@ -79,10 +79,14 @@     seihou agent bootstrap                    # single module     seihou agent bootstrap --repo             # multi-module with registry -UPGRADE WORKFLOW+PROJECT UPDATE AND CACHE UPGRADE -  Once installed, modules can be upgraded with `seihou upgrade`. If a newer-  module version ships migrations (for renames,-  deletions, etc.), the upgrade command surfaces them via an advisory; running-  `seihou migrate <module>` is the post-upgrade step that applies them-  to the current project. See `seihou help migrations`.+  After a module or recipe has been applied to a project, use `seihou update`+  for routine source updates. It stages the newer repository content, reuses+  saved inputs, applies migrations, reconciles generated files with user+  edits, and publishes the installed cache only after the project succeeds.++  `seihou upgrade` is a lower-level cache-maintenance command. It refreshes+  the shared installed copy but does not reconcile templates or user edits in+  the current project. Use it when cache-only maintenance is intentional.+  See `seihou help update` and `seihou help migrations`.
help/migrations.md view
@@ -1,5 +1,27 @@ MIGRATIONS +Seihou has two migration systems. Deterministic module migrations use typed+filesystem operations and `seihou migrate`. Agent-guided blueprint migrations+use one library-upgrade prompt per version edge and `seihou agent migrate`.++BLUEPRINT MIGRATIONS++  A library blueprint declares entries with `from`, `to`, and a Markdown+  `prompt`. Consumers always supply explicit dotted numeric versions:++    seihou agent migrate my-library --from 1.0.0 --to 3.0.0++  The planner orders in-window entries by `from` and permits gaps. Each entry+  runs in its own provider session, then receives an exact-edge receipt before+  the next starts. A normal rerun resumes after receipts; --rerun repeats them.+  Parent --debug renders pending prompts in order without launching a provider+  or changing the manifest. Migration mode never applies blueprint baseModules.++  A receipt means the agent interaction completed successfully. It does not+  verify that Cabal, npm, Cargo, or another package manager reports the target.++DETERMINISTIC MODULE MIGRATIONS+ A migration is an author-declared sequence of file-system operations that moves a project's working tree from one module version to another. When a module is upgraded — say, from haskell-base 1.0.0 to 2.0.0 — the project@@ -13,9 +35,9 @@   Add a migration when a new version of your module changes the *layout*   of the files it generates: a directory rename, a file removal, a path   pattern shift. You do not need a migration for content-only changes;-  re-running `seihou run <module>` already updates file content. Use-  migrations specifically for things that `seihou run` cannot infer on-  its own.+  `seihou update <target>` already reconciles newly generated content with+  the project. Use migrations specifically for layout changes that template+  regeneration cannot infer on its own.  THE MIGRATION RECORD @@ -135,28 +157,41 @@   For a machine-readable form, pass `--json` (works alongside   `--dry-run`). -UPGRADE INTEGRATION+PROJECT UPDATE INTEGRATION +  Routine project updates use `seihou update`. It fetches candidate sources+  into a staging area, plans applicable migrations automatically, and then+  reconciles the newly generated content with user edits. The migration and+  template transitions therefore share one preview, confirmation, recovery,+  and manifest-publication boundary.++  Use `seihou migrate <module>` directly for focused recovery or when you+  deliberately need its lower-level controls, such as an intermediate+  `--to VERSION`. See `seihou help update` for the routine workflow.++CACHE UPGRADE INTEGRATION+   `seihou upgrade` updates the central installed copy under   ~/.config/seihou/installed/<name>/ but does *not* rewrite project-  trees by default. After an upgrade that brings new migrations,-  `seihou upgrade` prints a one-line advisory:+  trees by default. After an upgrade that leaves a recorded project behind,+  `seihou upgrade` points the user at `seihou update` so migrations and+  generated content advance together:      note: haskell-base has 1 migration(s) pending (1.0.0 → 2.0.0);-          run 'seihou migrate haskell-base'+          run 'seihou update' to reconcile the recorded project application -  Pass `--with-migrations` to skip the advisory and run migrations-  for each upgraded module against the current project in one shot.+  `--with-migrations` remains available for compatibility when only the+  lower-level migration should run after refreshing the cache.  STATUS INTEGRATION    `seihou status` shows a `Pending migration: <from> -> <to> (<N>-  step(s)). Run: seihou migrate <name>` sub-line under any applied+  step(s)). Run: seihou update <target>` sub-line under any applied   module whose installed copy has advanced past the manifest's   recorded version. `<N>` is the count of in-window declared   migrations that will run; it may be zero (the version range has no   applicable migrations and the run will only advance the manifest).-  The line is informational — use `seihou migrate <module>` to apply.+  Recommendations are deduplicated by recorded top-level application.  MANIFEST GUARANTEE @@ -171,6 +206,8 @@  SEE ALSO +  seihou update --help+  seihou help update   seihou migrate --help   seihou upgrade --help   docs/user/migrations.md
help/modules.md view
@@ -80,6 +80,7 @@   seihou validate-module ./my-module   Check a module is well-formed   seihou install <git-url>             Install modules, recipes, or blueprints from git   seihou run <module> --var k=v        Run a module to generate files+  seihou update [target]                Update a recorded application and preserve user edits   seihou remove <module>               Remove an applied module and its files   seihou schema-upgrade ./my-module    Upgrade module.dhall to current schema @@ -88,5 +89,8 @@   Modules carry a `version` field that follows dotted-version semantics   (1.0.0, 1.2.3, etc.). When an author bumps a module's version and   changes its file layout, they can ship migrations alongside the new-  version that move existing project files into the new shape. Run-  `seihou help migrations` for the full reference.+  version that move existing project files into the new shape. For a+  recorded application, `seihou update` fetches the newer source, applies+  its migrations, regenerates files, and preserves user edits in one+  operation. Run `seihou help update` for the project workflow or+  `seihou help migrations` for the authoring reference.
+ help/update.md view
@@ -0,0 +1,117 @@+UPDATE++`seihou update` reconciles recorded module and recipe applications with newer+source content while preserving project-local edits. It is the routine way to+advance an existing project. `seihou run` remains the initial-generation and+explicit-reconfiguration command; `seihou upgrade` only refreshes the shared+installed cache.++BASIC WORKFLOW++  Preview every recorded application:++    seihou update --dry-run++  Update one recorded module or recipe application:++    seihou update master-plan++  Apply every recorded application in manifest order:++    seihou update++  Seihou stages newer source repositories before changing the installed cache,+  reuses the saved inputs for each module instance, plans migrations, renders+  new generated content, reconciles files, and runs only new or changed commands.+  The installed cache and manifest are published only after the managed update+  succeeds.++TARGET SELECTION++  TARGET may name a recorded module or recipe, or a module contained in a+  recorded application. Repeat TARGET to select several applications. With no+  target, every recorded application is selected.++  A targeted update stops if an unselected application also owns a path the+  selected applications would change. Name every required owner or run the+  no-target form; Seihou will not guess how to reconstruct an omitted layer.++SAVED INPUTS++  An ordinary update reuses the exact accepted value recorded for each module+  instance. Override a value with a repeatable `--var KEY=VALUE`. A newly added+  required variable follows the normal resolution chain and may prompt in an+  interactive terminal.++  `--reconfigure` intentionally ignores saved values and resolves every input+  again. Use it to change configuration, not for routine source updates.++MIGRATIONS++  Applicable module migrations are always included. Declarative moves and+  deletes are staged so `--dry-run` can show the post-migration file plan.+  Migration shell commands are listed but cannot be simulated or rolled back+  outside Seihou's managed paths. There is no skip-migrations flag because new+  templates against an old project layout would be unsafe.++THREE-WAY FILE RECONCILIATION++  For each generated text file, Seihou compares:++    baseline    Content generated by the previous successful application+    current     Content now on disk, including user edits+    generated   Content produced from the candidate source++  If only one side changed, that change wins. Non-overlapping user and module+  changes merge automatically. Overlapping edits become a conflict with diff3+  markers labeled current, baseline, and generated. Interactive runs let the+  user choose generated content, current content, conflict markers, or abort.++  An obsolete generated file is deleted only when it is unchanged. An edited+  orphan can be retained, detached, or explicitly deleted. `--force` accepts+  permitted deterministic conflict choices but retains edited orphans; it does+  not silently delete user data. Binary content that changed on both sides+  remains a conflict.++COMMANDS++  By default, only new or changed generated commands run. `--run-all-commands`+  executes every command and `--no-commands` executes none; those flags are+  mutually exclusive. Successful command receipts are published only when the+  entire command phase succeeds.++PREVIEW, AUTOMATION, AND COMMITS++  `--dry-run` prints the complete plan without changing project files, cache,+  baselines, or manifest. `--json` emits one machine-readable JSON document and+  disables prompts. Supply required values and conflict choices in advance for+  non-interactive use.++  `--commit` commits only managed paths from a successful update.+  `--commit-message MSG` supplies the message and implies `--commit`. Neither+  commit option can be combined with `--dry-run`.++TRANSACTION SAFETY++  Before mutation, Seihou checks that the manifest, project files, and staged+  sources still match the accepted plan. Managed migrations, files, baseline+  blobs, installed-cache entries, and the manifest are protected by recovery+  journals. The manifest is the final publication marker.++  External effects from module or migration shell commands cannot be undone.+  A failure reports that limitation instead of claiming a full rollback.++LEGACY PROJECTS++  A manifest without recorded applications needs one explicit TARGET for its+  first update. That successful run seeds application identity, saved inputs,+  ownership, generated baselines, and command receipts. Ambiguous legacy values+  are reported rather than guessed.++SEE ALSO++  seihou update --help+  seihou help modules+  seihou help migrations+  seihou status --help+  docs/cli/update.md
seihou-cli.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: seihou-cli-version: 0.4.0.0+version: 0.5.0.0 synopsis: CLI for Seihou project scaffolding description:   Command-line interface for Seihou, a composable project scaffolding@@ -18,6 +18,7 @@ build-type: Simple extra-source-files:   data/assist-prompt.md+  data/blueprint-migration-prompt.md   data/blueprint-prompt.md   data/bootstrap-prompt.md   data/prompt-run-prompt.md@@ -32,6 +33,7 @@   help/modules.md   help/prompts.md   help/templating.md+  help/update.md   help/variables.md  source-repository head@@ -53,9 +55,15 @@   exposed-modules:     Seihou.CLI.AgentCompletion     Seihou.CLI.AgentConfig+    Seihou.CLI.AgentConfigShow     Seihou.CLI.AgentLaunch+    Seihou.CLI.AgentModels     Seihou.CLI.AppliedBlueprint+    Seihou.CLI.AppliedBlueprintMigration+    Seihou.CLI.BlueprintExecution+    Seihou.CLI.BlueprintMigration     Seihou.CLI.BrowseFormat+    Seihou.CLI.CommandExecution     Seihou.CLI.CommitMessage     Seihou.CLI.Completions.Bash     Seihou.CLI.Completions.Fish@@ -79,6 +87,14 @@     Seihou.CLI.Shared     Seihou.CLI.StatusRender     Seihou.CLI.Style+    Seihou.CLI.Update+    Seihou.CLI.Update.Interaction+    Seihou.CLI.Update.Migrations+    Seihou.CLI.Update.Recovery+    Seihou.CLI.Update.Render+    Seihou.CLI.Update.Selection+    Seihou.CLI.Update.Source+    Seihou.CLI.Update.Types     Seihou.CLI.VersionCompare     Seihou.Effect.Fzf     Seihou.Effect.FzfInterp@@ -90,9 +106,9 @@     aeson >=2.1 && <3,     aeson-pretty >=0.8 && <1,     ansi-terminal >=1.1 && <2,-    baikai ^>=0.3.0.0,-    baikai-claude ^>=0.3.0.0,-    baikai-openai ^>=0.3.0.0,+    baikai ^>=0.4.0.0,+    baikai-claude ^>=0.3.0.1,+    baikai-openai ^>=0.3.0.1,     base >=4.18 && <5,     bytestring >=0.11 && <1,     containers >=0.6 && <1,@@ -101,7 +117,7 @@     file-embed >=0.0.15 && <1,     filepath >=1.4 && <2,     process >=1.6 && <2,-    seihou-core ^>=0.4.0.0,+    seihou-core ^>=0.5.0.0,     temporary >=1.3 && <2,     text >=2.0 && <3,     time >=1.12 && <2,@@ -116,6 +132,7 @@     OverloadedLabels     OverloadedRecordDot     OverloadedStrings+    PackageImports     TypeFamilies    hs-source-dirs: src-exe@@ -132,6 +149,7 @@   other-modules:     Paths_seihou_cli     Seihou.CLI.AgentLaunchExec+    Seihou.CLI.AgentMigrate     Seihou.CLI.AgentRun     Seihou.CLI.Assist     Seihou.CLI.Bootstrap@@ -154,6 +172,7 @@     Seihou.CLI.SchemaUpgrade     Seihou.CLI.Setup     Seihou.CLI.Status+    Seihou.CLI.Update     Seihou.CLI.Upgrade     Seihou.CLI.Validate     Seihou.CLI.ValidateBlueprint@@ -165,10 +184,10 @@     aeson >=2.1 && <3,     aeson-pretty >=0.8 && <1,     ansi-terminal >=1.1 && <2,-    baikai ^>=0.3.0.0,-    baikai-claude ^>=0.3.0.0,-    baikai-kit ^>=0.1.0.1,-    baikai-openai ^>=0.3.0.0,+    baikai ^>=0.4.0.0,+    baikai-claude ^>=0.3.0.1,+    baikai-kit ^>=0.1.0.2,+    baikai-openai ^>=0.3.0.1,     base >=4.18 && <5,     bytestring >=0.11 && <1,     containers >=0.6 && <1,@@ -180,7 +199,7 @@     optparse-applicative >=0.18 && <1,     process >=1.6 && <2,     seihou-cli-internal,-    seihou-core ^>=0.4.0.0,+    seihou-core ^>=0.5.0.0,     temporary >=1.3 && <2,     text >=2.0 && <3,     time >=1.12 && <2,@@ -202,10 +221,16 @@    other-modules:     Seihou.CLI.AgentCompletionSpec+    Seihou.CLI.AgentConfigShowSpec     Seihou.CLI.AgentConfigSpec     Seihou.CLI.AgentLaunchSpec+    Seihou.CLI.AgentMigrateE2ESpec+    Seihou.CLI.AgentModelsSpec+    Seihou.CLI.AppliedBlueprintMigrationSpec     Seihou.CLI.AppliedBlueprintSpec+    Seihou.CLI.BlueprintMigrationSpec     Seihou.CLI.BrowseFormatSpec+    Seihou.CLI.CommandExecutionSpec     Seihou.CLI.CommitMessageSpec     Seihou.CLI.DiffSpec     Seihou.CLI.ExtensionSpec@@ -222,12 +247,20 @@     Seihou.CLI.RunBlueprintRefusalSpec     Seihou.CLI.SavePromptedSpec     Seihou.CLI.StatusSpec+    Seihou.CLI.UpdateE2ESpec+    Seihou.CLI.UpdateFixture+    Seihou.CLI.UpdateInteractionSpec+    Seihou.CLI.UpdateRenderSpec+    Seihou.CLI.UpdateSpec     Seihou.CLI.UpgradeSpec     Seihou.FzfSpec +  build-tool-depends:+    seihou-cli:seihou+   build-depends:     aeson >=2.1 && <3,-    baikai ^>=0.3.0.0,+    baikai ^>=0.4.0.0,     base >=4.18 && <5,     bytestring >=0.11 && <1,     containers >=0.6 && <1,@@ -237,7 +270,7 @@     hspec >=2.11 && <3,     process >=1.6 && <2,     seihou-cli-internal,-    seihou-core ^>=0.4.0.0,+    seihou-core ^>=0.5.0.0,     tasty >=1.4 && <2,     tasty-hspec >=1.2 && <2,     temporary >=1.3 && <2,
src-exe/Main.hs view
@@ -2,12 +2,16 @@  import Control.Applicative ((<|>)) import Data.List (isPrefixOf)+import Data.Maybe (isJust) import Data.String (fromString) import Data.Text (Text) import Data.Text.IO qualified as TIO import Options.Applicative (customExecParser, prefs, showHelpOnEmpty) import Seihou.CLI.AgentCompletion qualified as AgentCompletion-import Seihou.CLI.AgentConfig (loadAgentModelConfig)+import Seihou.CLI.AgentConfig (AgentCommandName (..), loadAgentModelConfigFor)+import Seihou.CLI.AgentConfigShow (handleAgentConfigShow)+import Seihou.CLI.AgentMigrate (handleAgentMigrate)+import Seihou.CLI.AgentModels qualified as AgentModels import Seihou.CLI.AgentRun (handleAgentRun) import Seihou.CLI.Assist (handleAssist) import Seihou.CLI.Bootstrap (handleBootstrap)@@ -36,6 +40,7 @@ import Seihou.CLI.SchemaUpgrade (handleSchemaUpgrade) import Seihou.CLI.Setup (handleSetup) import Seihou.CLI.Status (handleStatus)+import Seihou.CLI.Update (handleUpdate) import Seihou.CLI.Upgrade (handleUpgrade) import Seihou.CLI.Validate (handleValidateModule) import Seihou.CLI.ValidateBlueprint (handleValidateBlueprint)@@ -75,6 +80,8 @@       handleInit     Run runOpts ->       handleRun runOpts+    Update updateOpts ->+      handleUpdate updateOpts     Remove removeOpts ->       handleRemove removeOpts     Vars varsOpts ->@@ -127,21 +134,42 @@     Agent agentOpts -> do       case agentOpts.agentCommand of         AgentAssist assistOpts -> do-          modelConfig <- resolveAgentModelConfig agentOpts.agentProvider agentOpts.agentModel assistOpts.assistProvider assistOpts.assistModel+          modelConfig <- resolveAgentModelConfigFor AgentCmdAssist agentOpts.agentProvider agentOpts.agentModel agentOpts.agentEffort assistOpts.assistProvider assistOpts.assistModel assistOpts.assistEffort           handleAssist agentOpts.agentDebug modelConfig assistOpts         AgentBootstrap bootstrapOpts -> do-          modelConfig <- resolveAgentModelConfig agentOpts.agentProvider agentOpts.agentModel bootstrapOpts.bootstrapProvider bootstrapOpts.bootstrapModel+          modelConfig <- resolveAgentModelConfigFor AgentCmdBootstrap agentOpts.agentProvider agentOpts.agentModel agentOpts.agentEffort bootstrapOpts.bootstrapProvider bootstrapOpts.bootstrapModel bootstrapOpts.bootstrapEffort           handleBootstrap agentOpts.agentDebug modelConfig bootstrapOpts         AgentSetup setupOpts -> do-          modelConfig <- resolveAgentModelConfig agentOpts.agentProvider agentOpts.agentModel setupOpts.setupProvider setupOpts.setupModel+          modelConfig <- resolveAgentModelConfigFor AgentCmdSetup agentOpts.agentProvider agentOpts.agentModel agentOpts.agentEffort setupOpts.setupProvider setupOpts.setupModel setupOpts.setupEffort           handleSetup agentOpts.agentDebug modelConfig setupOpts         AgentRun blueprintRunOpts -> do-          modelConfig <- resolveAgentModelConfig agentOpts.agentProvider agentOpts.agentModel blueprintRunOpts.runBlueprintProvider blueprintRunOpts.runBlueprintModel+          modelConfig <- resolveAgentModelConfigFor AgentCmdRun agentOpts.agentProvider agentOpts.agentModel agentOpts.agentEffort blueprintRunOpts.runBlueprintProvider blueprintRunOpts.runBlueprintModel blueprintRunOpts.runBlueprintEffort           handleAgentRun agentOpts.agentDebug modelConfig blueprintRunOpts+        AgentMigrate migrationOpts -> do+          modelConfig <- resolveAgentModelConfigFor AgentCmdMigrate agentOpts.agentProvider agentOpts.agentModel agentOpts.agentEffort migrationOpts.migrateBlueprintProvider migrationOpts.migrateBlueprintModel migrationOpts.migrateBlueprintEffort+          handleAgentMigrate agentOpts.agentDebug modelConfig migrationOpts+        AgentModels modelsOpts ->+          case agentOpts.agentModel of+            Just _ -> do+              TIO.putStrLn "Error: --model does not apply to 'seihou agent models'; omit it to list known choices."+              exitFailure+            Nothing ->+              case modelsOpts.modelsProvider <|> agentOpts.agentProvider of+                Nothing ->+                  TIO.putStr (AgentModels.formatAgentModels Nothing AgentModels.availableAgentModels)+                Just providerText ->+                  case AgentCompletion.providerFromText providerText of+                    Left err -> do+                      TIO.putStrLn $ "Error: " <> err+                      exitFailure+                    Right provider ->+                      TIO.putStr (AgentModels.formatAgentModels (Just provider) AgentModels.availableAgentModels)+        AgentConfigShow ->+          handleAgentConfigShow     Prompt promptCmd -> do       case promptCmd of         PromptRun promptRunOpts -> do-          modelConfig <- resolveAgentModelConfig Nothing Nothing promptRunOpts.runPromptProvider promptRunOpts.runPromptModel+          modelConfig <- resolveAgentModelConfigFor AgentCmdPromptRun Nothing Nothing Nothing promptRunOpts.runPromptProvider promptRunOpts.runPromptModel promptRunOpts.runPromptEffort           handlePromptRun modelConfig promptRunOpts     Extension extensionCmd -> do       case extensionCmd of@@ -152,9 +180,27 @@     Completions completionsCmd ->       handleCompletionsCommand completionsCmd -resolveAgentModelConfig :: Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> IO AgentCompletion.AgentModelConfig-resolveAgentModelConfig parentProvider parentModel commandProvider commandModel = do-  configResult <- loadAgentModelConfig (commandProvider <|> parentProvider) (commandModel <|> parentModel)+-- | Resolve the effective provider/model/effort for one agent command. The+-- subcommand flag wins over the parent @seihou agent@ flag; that combined flag+-- then feeds the per-command config resolution, which also consults the+-- command's own @agent.<command>.*@ keys before the shared @agent.*@ defaults.+resolveAgentModelConfigFor ::+  AgentCommandName ->+  -- | parent @seihou agent@ provider, model, effort+  Maybe Text ->+  Maybe Text ->+  Maybe Text ->+  -- | subcommand provider, model, effort+  Maybe Text ->+  Maybe Text ->+  Maybe Text ->+  IO AgentCompletion.AgentModelConfig+resolveAgentModelConfigFor cmd parentProvider parentModel parentEffort commandProvider commandModel commandEffort = do+  let provider = commandProvider <|> parentProvider+      model = commandModel <|> parentModel+      effort = commandEffort <|> parentEffort+  configResult <-+    loadAgentModelConfigFor cmd provider model effort (isJust commandProvider) (isJust commandModel) (isJust commandEffort)   case configResult of     Left err -> do       TIO.putStrLn $ "Error: " <> err
src-exe/Seihou/CLI/AgentLaunchExec.hs view
@@ -10,6 +10,7 @@     CodexSandboxMode (CodexWorkspaceWrite),     InteractiveLaunchResult (..),     InteractiveSafety (ClaudeAllowedTools, CodexSandbox),+    effort,     extraDirs,     interactiveLaunchRequest,     modelId,@@ -26,6 +27,7 @@   ( defaultCodexInteractiveConfig,     launchCodexInteractive,   )+import Baikai.ThinkingLevel (ThinkingLevel) import Data.Text qualified as T import Data.Text.IO qualified as TIO import Seihou.CLI.AgentCompletion (AgentModelConfig (..), AgentProvider (..))@@ -50,16 +52,16 @@   | otherwise =       case modelConfig.agentProvider of         AgentProviderClaudeCli ->-          launchClaude addDirs tools modelConfig.agentModel systemPrompt initialPrompt+          launchClaude addDirs tools modelConfig.agentModel modelConfig.agentEffort systemPrompt initialPrompt         AgentProviderCodexCli ->-          launchCodex addDirs modelConfig.agentModel systemPrompt initialPrompt+          launchCodex addDirs modelConfig.agentModel modelConfig.agentEffort systemPrompt initialPrompt         AgentProviderAnthropic ->           unsupportedInteractiveProvider "anthropic"         AgentProviderOpenAI ->           unsupportedInteractiveProvider "openai" -launchClaude :: [FilePath] -> [String] -> Maybe Text -> Text -> Maybe Text -> IO ExitCode-launchClaude addDirs tools model systemPrompt initialPrompt = do+launchClaude :: [FilePath] -> [String] -> Maybe Text -> Maybe ThinkingLevel -> Text -> Maybe Text -> IO ExitCode+launchClaude addDirs tools model effortLevel systemPrompt initialPrompt = do   claudePath <- findExecutable "claude"   case claudePath of     Nothing -> do@@ -74,14 +76,15 @@           (interactiveLaunchRequest (promptOrEmpty initialPrompt))             { systemPrompt = Just systemPrompt,               modelId = model,+              effort = effortLevel,               workingDir = Just cwd,               extraDirs = addDirs,               safety = ClaudeAllowedTools (map T.pack tools)             }       pure exitCode -launchCodex :: [FilePath] -> Maybe Text -> Text -> Maybe Text -> IO ExitCode-launchCodex addDirs model systemPrompt initialPrompt = do+launchCodex :: [FilePath] -> Maybe Text -> Maybe ThinkingLevel -> Text -> Maybe Text -> IO ExitCode+launchCodex addDirs model effortLevel systemPrompt initialPrompt = do   codexPath <- findExecutable "codex"   case codexPath of     Nothing -> do@@ -96,6 +99,7 @@           (interactiveLaunchRequest (promptOrEmpty initialPrompt))             { systemPrompt = Just systemPrompt,               modelId = model,+              effort = effortLevel,               workingDir = Just cwd,               extraDirs = addDirs,               safety = CodexSandbox CodexWorkspaceWrite CodexApprovalOnRequest
+ src-exe/Seihou/CLI/AgentMigrate.hs view
@@ -0,0 +1,337 @@+{-# LANGUAGE TemplateHaskell #-}++module Seihou.CLI.AgentMigrate+  ( handleAgentMigrate,+  )+where++import Data.FileEmbed (embedFile)+import Data.Maybe (maybeToList)+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.Text.IO qualified as TIO+import Data.Time.Clock (getCurrentTime)+import Seihou.CLI.AgentCompletion+  ( AgentModelConfig (..),+    AgentProvider (..),+    buildAgentCompletionRequest,+    runAgentCompletion,+  )+import Seihou.CLI.AgentLaunch (gatherAgentContext)+import Seihou.CLI.AgentLaunchExec (launchConfiguredAgentAddingDirs)+import Seihou.CLI.AppliedBlueprintMigration (recordAppliedBlueprintMigration)+import Seihou.CLI.BlueprintExecution+  ( BlueprintExecutionRequest (..),+    PreparedBlueprintExecution (..),+    prepareBlueprintExecution,+  )+import Seihou.CLI.BlueprintMigration+  ( BlueprintMigrationLaunchFailure (..),+    BlueprintMigrationRunResult (..),+    formatBlueprintMigrationDebugOutput,+    pendingBlueprintMigrations,+    renderBlueprintMigrationSystemPrompt,+    runBlueprintMigrationsWith,+  )+import Seihou.CLI.Commands (BlueprintMigrationOpts (..))+import Seihou.CLI.Shared (formatVarError, logIO)+import Seihou.Core.Blueprint (validateBlueprint)+import Seihou.Core.Migration+  ( BlueprintMigration (..),+    BlueprintMigrationPlan (..),+    MigrationPlanError (..),+    planBlueprintMigrationChain,+  )+import Seihou.Core.Module (defaultSearchPaths, discoverRunnable)+import Seihou.Core.Types+import Seihou.Core.Version (Version, parseVersion, renderVersion)+import Seihou.Effect.FilesystemInterp (runFilesystem)+import Seihou.Effect.Logger (logError)+import Seihou.Effect.ManifestStore (readManifest)+import Seihou.Effect.ManifestStoreInterp (runManifestStore)+import Seihou.Prelude+import System.Exit (ExitCode (..), exitFailure, exitWith)++migrationPromptTemplate :: Text+migrationPromptTemplate = TE.decodeUtf8 $(embedFile "data/blueprint-migration-prompt.md")++handleAgentMigrate :: Bool -> AgentModelConfig -> BlueprintMigrationOpts -> IO ()+handleAgentMigrate debug modelConfig opts = do+  let level = if opts.migrateBlueprintVerbose then LogVerbose else LogNormal+      manifestPath = ".seihou" </> "manifest.json"++  (blueprint, blueprintDir) <- discoverMigrationBlueprint level opts.migrateBlueprintName+  validationResult <- validateBlueprint blueprintDir blueprint+  case validationResult of+    Left err -> exitErr level (renderModuleLoadError err)+    Right _ -> pure ()++  current <- parseRequestedVersion level "--from" opts.migrateBlueprintFrom+  target <- parseRequestedVersion level "--to" opts.migrateBlueprintTo+  planned <-+    case planBlueprintMigrationChain blueprint.name.unModuleName blueprint.migrations current target of+      Left err -> exitErr level (renderPlanError err)+      Right Nothing -> do+        TIO.putStrLn "No blueprint migration needed: --from and --to resolve to the same version."+        pure Nothing+      Right (Just migrationPlan) -> pure (Just migrationPlan)++  case planned of+    Nothing -> pure ()+    Just migrationPlan -> do+      receipts <- readMigrationReceipts level manifestPath+      let pending =+            pendingBlueprintMigrations+              opts.migrateBlueprintRerun+              blueprint.name+              receipts+              migrationPlan+      if null pending+        then reportNoPending migrationPlan+        else do+          prepared <- prepare level modelConfig opts blueprint blueprintDir+          context <- gatherAgentContext+          let renderStep position total migration =+                renderBlueprintMigrationSystemPrompt+                  migrationPromptTemplate+                  context+                  prepared+                  position+                  total+                  migration+              renderDebugStep position total migration =+                renderStep position total migration+                  <> maybe+                    ""+                    ("\n\n===== Initial user instruction =====\n" <>)+                    opts.migrateBlueprintPrompt++          if debug+            then+              TIO.putStrLn $+                "Blueprint migrations for "+                  <> blueprint.name.unModuleName+                  <> ": "+                  <> renderVersion migrationPlan.blueprintPlanFrom+                  <> " -> "+                  <> renderVersion migrationPlan.blueprintPlanTo+                  <> "\n"+                  <> formatBlueprintMigrationDebugOutput renderDebugStep pending+            else do+              result <-+                runBlueprintMigrationsWith+                  (launchMigration modelConfig opts prepared renderStep)+                  (recordMigration manifestPath blueprint)+                  pending+              handleRunResult level blueprint.name result++discoverMigrationBlueprint :: LogLevel -> ModuleName -> IO (Blueprint, FilePath)+discoverMigrationBlueprint level requestedName = do+  searchPaths <- defaultSearchPaths+  runnableResult <- discoverRunnable searchPaths requestedName+  case runnableResult of+    Right (RunnableBlueprint blueprint dir) -> pure (blueprint, dir)+    Right (RunnableModule _ _) ->+      exitErr level $ "'" <> requestedName.unModuleName <> "' is a module, not a blueprint."+    Right (RunnableRecipe _ _) ->+      exitErr level $ "'" <> requestedName.unModuleName <> "' is a recipe, not a blueprint."+    Right (RunnableAgentPrompt _ _) ->+      exitErr level $ "'" <> requestedName.unModuleName <> "' is a prompt, not a blueprint."+    Left err -> exitErr level (renderModuleLoadError err)++parseRequestedVersion :: LogLevel -> Text -> Text -> IO Version+parseRequestedVersion level flag raw =+  case parseVersion raw of+    Just version -> pure version+    Nothing -> exitErr level (flag <> " value '" <> raw <> "' is not a valid dotted numeric version.")++readMigrationReceipts :: LogLevel -> FilePath -> IO [AppliedBlueprintMigration]+readMigrationReceipts level manifestPath = do+  result <- runEff $ runFilesystem $ runManifestStore manifestPath readManifest+  case result of+    Left err -> exitErr level ("Error reading migration receipts: " <> err)+    Right Nothing -> pure []+    Right (Just manifest) -> pure manifest.blueprintMigrations++prepare ::+  LogLevel ->+  AgentModelConfig ->+  BlueprintMigrationOpts ->+  Blueprint ->+  FilePath ->+  IO PreparedBlueprintExecution+prepare level modelConfig opts blueprint blueprintDir = do+  let providerCanMountFiles =+        modelConfig.agentProvider == AgentProviderClaudeCli+          || modelConfig.agentProvider == AgentProviderCodexCli+  result <-+    prepareBlueprintExecution+      BlueprintExecutionRequest+        { executionBlueprint = blueprint,+          executionBlueprintDir = blueprintDir,+          executionVariableOverrides = opts.migrateBlueprintVars,+          executionNamespaceOverride = opts.migrateBlueprintNamespace,+          executionContextOverride = opts.migrateBlueprintContext,+          executionCanMountFiles = providerCanMountFiles,+          executionLogLevel = level+        }+  case result of+    Left errs -> do+      logIO level $ logError "Error resolving blueprint migration variables:"+      mapM_ (logIO level . logError . ("  " <>) . formatVarError) errs+      exitFailure+    Right prepared -> pure prepared++launchMigration ::+  AgentModelConfig ->+  BlueprintMigrationOpts ->+  PreparedBlueprintExecution ->+  (Int -> Int -> BlueprintMigration -> Text) ->+  Int ->+  Int ->+  BlueprintMigration ->+  IO (Either BlueprintMigrationLaunchFailure ())+launchMigration modelConfig opts prepared renderStep position total migration = do+  TIO.putStrLn $+    "Running blueprint migration "+      <> T.pack (show position)+      <> "/"+      <> T.pack (show total)+      <> ": "+      <> migration.from+      <> " -> "+      <> migration.to+  let systemPrompt = renderStep position total migration+  case modelConfig.agentProvider of+    AgentProviderClaudeCli -> launchInteractive systemPrompt+    AgentProviderCodexCli -> launchInteractive systemPrompt+    AgentProviderAnthropic -> launchCompletion systemPrompt+    AgentProviderOpenAI -> launchCompletion systemPrompt+  where+    launchInteractive systemPrompt = do+      exitCode <-+        launchConfiguredAgentAddingDirs+          (maybeToList prepared.preparedMountedFilesDir)+          modelConfig+          prepared.preparedAllowedTools+          False+          systemPrompt+          opts.migrateBlueprintPrompt+      pure $ case exitCode of+        ExitSuccess -> Right ()+        failure -> Left (BlueprintMigrationProcessFailure failure)++    launchCompletion systemPrompt = do+      result <-+        runAgentCompletion+          (buildAgentCompletionRequest modelConfig systemPrompt opts.migrateBlueprintPrompt)+      case result of+        Left err -> pure (Left (BlueprintMigrationProviderFailure err))+        Right assistantText -> do+          TIO.putStrLn assistantText+          pure (Right ())++recordMigration ::+  FilePath ->+  Blueprint ->+  BlueprintMigration ->+  IO (Either Text ())+recordMigration manifestPath blueprint migration = do+  now <- getCurrentTime+  recordAppliedBlueprintMigration+    manifestPath+    AppliedBlueprintMigration+      { name = blueprint.name,+        blueprintVersion = blueprint.version,+        fromVersion = migration.from,+        toVersion = migration.to,+        appliedAt = now,+        agentSessionId = Nothing+      }++handleRunResult :: LogLevel -> ModuleName -> BlueprintMigrationRunResult -> IO ()+handleRunResult level blueprintName = \case+  BlueprintMigrationNoWork ->+    TIO.putStrLn "No pending blueprint migrations."+  BlueprintMigrationComplete completed ->+    TIO.putStrLn $+      "Completed "+        <> T.pack (show (length completed))+        <> " blueprint migration(s) for '"+        <> blueprintName.unModuleName+        <> "'."+  BlueprintMigrationLaunchFailed migration failure -> do+    let prefix =+          "Blueprint migration "+            <> migration.from+            <> " -> "+            <> migration.to+            <> " failed; completed earlier edges remain recorded. "+        retry = "Fix the provider error, then rerun the same command to resume."+    case failure of+      BlueprintMigrationProcessFailure exitCode -> do+        logIO level $ logError $ prefix <> "Provider exited with " <> T.pack (show exitCode) <> ". " <> retry+        exitWith exitCode+      BlueprintMigrationProviderFailure err -> do+        logIO level $ logError $ prefix <> err <> " " <> retry+        exitFailure+  BlueprintMigrationRecordFailed migration err -> do+    logIO level $+      logError $+        "Agent completed blueprint migration "+          <> migration.from+          <> " -> "+          <> migration.to+          <> ", but its receipt could not be recorded: "+          <> err+          <> ". The next edge was not started; repair manifest access, then rerun the same command."+    exitFailure++reportNoPending :: BlueprintMigrationPlan -> IO ()+reportNoPending migrationPlan+  | null migrationPlan.blueprintPlanSteps =+      TIO.putStrLn "No blueprint migrations are declared inside the requested version window."+  | otherwise =+      TIO.putStrLn "All blueprint migrations in the requested version window already have receipts."++renderPlanError :: MigrationPlanError -> Text+renderPlanError (MigrationVersionUnparseable raw) =+  "the blueprint declares an unparseable migration version: '" <> raw <> "'."+renderPlanError (MigrationDowngradeNotSupported current target) =+  "blueprint migration downgrades are not supported: --from "+    <> renderVersion current+    <> ", --to "+    <> renderVersion target+    <> "."+renderPlanError (MigrationDuplicateEdge fromVersion _) =+  "the blueprint declares more than one migration starting at "+    <> renderVersion fromVersion+    <> "; the author must merge or remove the duplicate."++renderModuleLoadError :: ModuleLoadError -> Text+renderModuleLoadError = \case+  ModuleNotFound name searched ->+    "Blueprint '"+      <> name.unModuleName+      <> "' not found. Searched in:\n"+      <> T.intercalate "\n" (map (("  " <>) . T.pack) searched)+  DhallEvalError name msg ->+    "Failed to evaluate '" <> name.unModuleName <> "': " <> msg+  DhallDecodeError name msg ->+    "Failed to decode '" <> name.unModuleName <> "': " <> msg+  ValidationError name msgs ->+    "Validation failed for '"+      <> name.unModuleName+      <> "':\n"+      <> T.intercalate "\n" (map ("  " <>) msgs)+  CircularDependency names ->+    "Circular dependency detected: " <> T.intercalate " -> " (map (.unModuleName) names)+  MissingSourceFile name path ->+    "Missing source file in '" <> name.unModuleName <> "': " <> T.pack path+  RegistryEvalError path msg ->+    "Failed to evaluate registry at '" <> path <> "': " <> msg++exitErr :: LogLevel -> Text -> IO a+exitErr level msg = do+  logIO level (logError msg)+  exitFailure
src-exe/Seihou/CLI/AgentRun.hs view
@@ -12,6 +12,7 @@   ) where +import Control.Exception (IOException, displayException, try) import Control.Monad (when) import Data.FileEmbed (embedFile) import Data.Map.Strict qualified as Map@@ -36,15 +37,18 @@     formatLocalModules,     formatManifestState,     formatModuleDhallState,-    formatReferenceFiles,-    formatReferenceFilesDir,     formatSeihouProjectState,     gatherAgentContext,-    resolveBlueprintTools,     substitute,   ) import Seihou.CLI.AgentLaunchExec (launchConfiguredAgentAddingDirs) import Seihou.CLI.AppliedBlueprint (recordAppliedBlueprint)+import Seihou.CLI.BlueprintExecution+  ( BlueprintExecutionRequest (..),+    PreparedBlueprintExecution (..),+    prepareBlueprintExecution,+    varValueToText,+  ) import Seihou.CLI.Commands (BlueprintRunOpts (..)) import Seihou.CLI.Shared   ( deriveNamespace,@@ -53,12 +57,14 @@     toVarNameMap,     unwrapConfig,   )-import Seihou.Composition.Instance (ModuleInstance (..), primaryInstance, qualifiedName)+import Seihou.Composition.Instance (ModuleInstance (..), qualifiedName) import Seihou.Composition.Plan (compileComposedPlan) import Seihou.Composition.Resolve (loadComposition, resolveWithPrompts) import Seihou.Core.Context (resolveContext) import Seihou.Core.Module (defaultSearchPaths, discoverRunnable) import Seihou.Core.Types+import Seihou.Effect.BaselineStore (pruneBaselines)+import Seihou.Effect.BaselineStoreInterp (runBaselineStore) import Seihou.Effect.ConfigReader   ( readContextConfig,     readGlobalConfig,@@ -69,15 +75,15 @@ import Seihou.Effect.ConsoleInterp (runConsole) import Seihou.Effect.Filesystem (createDirectoryIfMissing) import Seihou.Effect.FilesystemInterp (runFilesystem)-import Seihou.Effect.Logger (logError, logInfo)+import Seihou.Effect.Logger (logError, logInfo, logWarn) import Seihou.Effect.ManifestStore (readManifest, writeManifest) import Seihou.Effect.ManifestStoreInterp (runManifestStore)+import Seihou.Engine.Baseline (manifestBaselineRefs, recordGeneratedBaselines) import Seihou.Engine.Conflict (resolveConflicts) import Seihou.Engine.Diff (computeDiff) import Seihou.Engine.Execute (executePlan) import Seihou.Manifest.Types (currentManifestVersion, emptyManifest) import Seihou.Prelude-import System.Directory (doesDirectoryExist, makeAbsolute) import System.Environment (getEnvironment) import System.Exit (ExitCode (..), exitFailure, exitWith) import System.FilePath (takeDirectory, (</>))@@ -112,66 +118,29 @@           <> "'?"     Left err -> exitErr level (renderModuleLoadError err) -  let filesDir = blueprintDir </> "files"-      providerCanMountFiles =+  let providerCanMountFiles =         modelConfig.agentProvider == AgentProviderClaudeCli           || modelConfig.agentProvider == AgentProviderCodexCli-  filesExist <- doesDirectoryExist filesDir-  mFilesDir <--    if filesExist && providerCanMountFiles-      then Just <$> makeAbsolute filesDir-      else pure Nothing--  -- (b) Resolve blueprint variables. Wrap the blueprint's vars/prompts-  -- in a placeholder Module so 'resolveWithPrompts' can run the-  -- standard precedence chain (CLI > env > local > namespace > context-  -- > global > defaults > interactive prompts).-  let placeholderModule =-        Module-          { name = bp.name,-            version = bp.version,-            description = bp.description,-            vars = bp.vars,-            exports = [],-            prompts = bp.prompts,-            steps = [],-            commands = [],-            dependencies = [],-            removal = Nothing,-            migrations = []-          }-      placeholderInst = primaryInstance bp.name-      placeholderTriple = (placeholderInst, placeholderModule, blueprintDir)--  envPairs <- getEnvironment-  let cliOverrides = Map.fromList [(VarName k, v) | (k, v) <- opts.runBlueprintVars]-      envVars = Map.fromList [(T.pack k, T.pack v) | (k, v) <- envPairs]-      namespace = fromMaybe (deriveNamespace bp.name) opts.runBlueprintNamespace-  context <- resolveContext opts.runBlueprintContext envVars-  let contextName = fromMaybe "" context--  resolveResult <- runEff $ runConfigReader $ runConsole $ do-    localCfg <- readLocalConfig >>= unwrapConfig level-    nsCfg <- readNamespaceConfig namespace >>= unwrapConfig level-    ctxCfg <- readContextConfig contextName >>= unwrapConfig level-    gCfg <- readGlobalConfig >>= unwrapConfig level-    resolveWithPrompts-      [placeholderTriple]-      cliOverrides-      envVars-      namespace-      contextName-      (toVarNameMap localCfg)-      (toVarNameMap nsCfg)-      (toVarNameMap ctxCfg)-      (toVarNameMap gCfg)--  resolved <- case resolveResult of+  -- (b) Resolve variables and prepare the shared prompt/reference/tool state.+  preparedResult <-+    prepareBlueprintExecution+      BlueprintExecutionRequest+        { executionBlueprint = bp,+          executionBlueprintDir = blueprintDir,+          executionVariableOverrides = opts.runBlueprintVars,+          executionNamespaceOverride = opts.runBlueprintNamespace,+          executionContextOverride = opts.runBlueprintContext,+          executionCanMountFiles = providerCanMountFiles,+          executionLogLevel = level+        }+  prepared <- case preparedResult of     Left errs -> do       logIO level $ logError "Error resolving blueprint variables:"       mapM_ (logIO level . logError . ("  " <>) . formatVarError) errs       exitFailure-    Right r -> pure (Map.findWithDefault Map.empty placeholderInst r)+    Right result -> pure result+  let resolved = prepared.preparedResolvedVariables+      cliOverrides = Map.fromList [(VarName k, v) | (k, v) <- opts.runBlueprintVars]    -- (c) Baseline.   baseline <-@@ -182,20 +151,17 @@           then pure BaselineEmpty           else applyBaseline level opts bp.baseModules cliOverrides resolved -  -- (d) Render the user prompt: substitute resolved vars into bp.prompt.-  let renderedUser = renderUserPrompt resolved bp.prompt--  -- (e) Render the system prompt around the user body.+  -- (d) Render the system prompt around the prepared shared body.   ctx <- gatherAgentContext-  let systemPrompt = renderSystemPrompt ctx bp baseline mFilesDir renderedUser+  let systemPrompt = renderSystemPrompt ctx prepared baseline    -- (f) Launch.   launchSucceeded <-     runRenderedAgentPrompt       debug       modelConfig-      (resolveBlueprintTools bp.allowedTools)-      mFilesDir+      prepared.preparedAllowedTools+      prepared.preparedMountedFilesDir       systemPrompt       opts.runBlueprintPrompt @@ -347,6 +313,7 @@   -- write the manifest. Mirrors Seihou.CLI.Run.handleRun.   now <- getCurrentTime   let manifestPath = ".seihou" </> "manifest.json"+      baselineDir = ".seihou" </> "baselines"       planned =         [(dest, content, primary, Nothing) | WriteFileOp dest content _ <- ops]           ++ [(dest, content, mName, Just pOp) | PatchFileOp dest content pOp _ mName <- ops]@@ -384,7 +351,9 @@                         { hash = c.diskHash,                           moduleName = c.moduleName,                           strategy = Template,-                          generatedAt = now+                          generatedAt = now,+                          baseline = Nothing,+                          applicationIds = mempty                         }                 )               | (c, KeepCurrent) <- conflictResolved@@ -393,26 +362,57 @@           excludePaths = Set.fromList (Map.keys keepRecords ++ skipPaths)           opsForExec = filter (not . opTargetsPath excludePaths) ops -      runEff $ runFilesystem $ runManifestStore manifestPath $ do-        recs <- executePlan "" opsForExec ownerMap primary now+      generationAttempt <-+        try @IOException $+          runEff $+            runFilesystem $+              runBaselineStore baselineDir $+                runManifestStore manifestPath $ do+                  recs <- executePlan "" opsForExec ownerMap primary now+                  baselineResult <- recordGeneratedBaselines "" recs+                  case baselineResult of+                    Left err -> pure (Left err)+                    Right baselineRecords -> do+                      let orphanedPaths = map (.path) diff.orphaned+                          cleanedFiles = foldr Map.delete manifest.files orphanedPaths+                          allModuleEntries = updateAllModules manifest.modules modulesInOrder now+                          allResolvedVals =+                            Map.unions [Map.map (.value) vs | vs <- Map.elems baseResolved]+                          newManifest =+                            Manifest+                              { version = currentManifestVersion,+                                genAt = now,+                                modules = allModuleEntries,+                                vars = Map.union (Map.map varValueToText allResolvedVals) manifest.vars,+                                files = Map.unions [baselineRecords, keepRecords, cleanedFiles],+                                applications = manifest.applications,+                                recipe = manifest.recipe,+                                blueprint = manifest.blueprint,+                                blueprintMigrations = manifest.blueprintMigrations+                              }+                      writeManifest newManifest+                      pure (Right newManifest) -        let orphanedPaths = map (.path) diff.orphaned-            cleanedFiles = foldr Map.delete manifest.files orphanedPaths-            allModuleEntries = updateAllModules manifest.modules modulesInOrder now-            allResolvedVals =-              Map.unions [Map.map (.value) vs | vs <- Map.elems baseResolved]-            newManifest =-              Manifest-                { version = currentManifestVersion,-                  genAt = now,-                  modules = allModuleEntries,-                  vars = Map.union (Map.map varValueToText allResolvedVals) manifest.vars,-                  files = Map.unions [recs, keepRecords, cleanedFiles],-                  recipe = manifest.recipe,-                  blueprint = manifest.blueprint-                }-        writeManifest newManifest+      newManifest <- case generationAttempt of+        Left err -> do+          logIO level $ logError $ "Error applying baseline files or storing generated baselines: " <> T.pack (displayException err)+          exitFailure+        Right (Left err) -> do+          logIO level $ logError $ "Error storing generated baselines: " <> T.pack (show err)+          exitFailure+        Right (Right saved) -> pure saved +      pruneAttempt <-+        try @IOException $+          runEff $+            runFilesystem $+              runBaselineStore baselineDir $+                pruneBaselines (manifestBaselineRefs newManifest)+      case pruneAttempt of+        Left err ->+          logIO level $ logWarn $ "Warning: could not prune generated baselines: " <> T.pack (displayException err)+        Right _ -> pure ()+       let nNew = length diff.new           nMod = length diff.modified           nUnch = length diff.unchanged@@ -431,40 +431,25 @@  -- | Stitch the system-prompt template together. Each block in -- @blueprint-prompt.md@ has a @{{key}}@ placeholder filled here.-renderSystemPrompt :: AgentContext -> Blueprint -> BaselineStatus -> Maybe FilePath -> Text -> Text-renderSystemPrompt ctx bp baseline mFilesDir userPrompt =-  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", bp.name.unModuleName),-      ("blueprint_version", fromMaybe "(unspecified)" bp.version),-      ("blueprint_description", fromMaybe "(no description)" bp.description),-      ("baseline_status", formatBaselineStatus baseline),-      ("reference_files", formatReferenceFiles bp.files),-      ("reference_files_dir", formatReferenceFilesDir mFilesDir),-      ("user_prompt", userPrompt)-    ]-    promptTemplate---- | Substitute resolved blueprint variables into the user prompt body.-renderUserPrompt :: Map VarName ResolvedVar -> Text -> Text-renderUserPrompt resolved tpl =-  substitute-    [(vn.unVarName, varValueToText rv.value) | (vn, rv) <- Map.toList resolved]-    tpl---- | Local copy of @Seihou.CLI.Run.varValueToText@. Kept in sync with the--- original; if a third caller appears, lift it into Seihou.CLI.Shared.-varValueToText :: VarValue -> Text-varValueToText (VText t) = t-varValueToText (VBool True) = "true"-varValueToText (VBool False) = "false"-varValueToText (VInt n) = T.pack (show n)-varValueToText (VList vs) = T.intercalate "," (map varValueToText vs)+renderSystemPrompt :: AgentContext -> PreparedBlueprintExecution -> BaselineStatus -> Text+renderSystemPrompt ctx prepared baseline =+  let bp = prepared.preparedBlueprint+   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", bp.name.unModuleName),+          ("blueprint_version", fromMaybe "(unspecified)" bp.version),+          ("blueprint_description", fromMaybe "(no description)" bp.description),+          ("baseline_status", formatBaselineStatus baseline),+          ("reference_files", prepared.preparedReferenceFiles),+          ("reference_files_dir", prepared.preparedReferenceFilesAccess),+          ("user_prompt", prepared.preparedSharedPrompt)+        ]+        promptTemplate  -- | Whether an operation targets a file in the given path set. Local -- copy of @Seihou.CLI.Run.opTargetsPath@.
src-exe/Seihou/CLI/Browse.hs view
@@ -68,8 +68,8 @@             logIO LogNormal (logError $ "failed to load blueprint: " <> T.pack (show err))             exitFailure           Right b -> do-            let bpName = case b of Blueprint nm _ _ _ _ _ _ _ _ _ -> nm-                bpDesc = case b of Blueprint _ _ d _ _ _ _ _ _ _ -> d+            let bpName = case b of Blueprint nm _ _ _ _ _ _ _ _ _ _ -> nm+                bpDesc = case b of Blueprint _ _ d _ _ _ _ _ _ _ _ -> d             TIO.putStr $ formatBrowseSingleBlueprint source bpName.unModuleName bpDesc       SinglePrompt rootDir -> do         let dhallFile = rootDir </> "prompt.dhall"
src-exe/Seihou/CLI/Commands.hs view
@@ -1,6 +1,7 @@ module Seihou.CLI.Commands   ( Command (..),     RunOpts (..),+    UpdateOpts (..),     RemoveOpts (..),     VarsOpts (..),     InstallOpts (..),@@ -22,11 +23,13 @@     MigrateOpts (..),     SchemaUpgradeOpts (..),     AgentOpts (..),+    AgentModelsOpts (..),     AgentCommand (..),     AssistOpts (..),     BootstrapOpts (..),     SetupOpts (..),     BlueprintRunOpts (..),+    BlueprintMigrationOpts (..),     PromptCommand (..),     PromptRunOpts (..),     CompletionsCommand (..),@@ -59,6 +62,7 @@ data Command   = Init   | Run RunOpts+  | Update UpdateOpts   | Remove RemoveOpts   | Vars VarsOpts   | Install InstallOpts@@ -102,15 +106,24 @@   { agentDebug :: Bool,     agentProvider :: Maybe Text,     agentModel :: Maybe Text,+    agentEffort :: Maybe Text,     agentCommand :: AgentCommand   }   deriving stock (Eq, Show, Generic) +data AgentModelsOpts = AgentModelsOpts+  { modelsProvider :: Maybe Text+  }+  deriving stock (Eq, Show, Generic)+ data AgentCommand   = AgentAssist AssistOpts   | AgentBootstrap BootstrapOpts   | AgentSetup SetupOpts   | AgentRun BlueprintRunOpts+  | AgentMigrate BlueprintMigrationOpts+  | AgentModels AgentModelsOpts+  | AgentConfigShow   deriving stock (Eq, Show, Generic)  data RunOpts = RunOpts@@ -139,6 +152,20 @@   }   deriving stock (Eq, Show, Generic) +data UpdateOpts = UpdateOpts+  { updateTargets :: [Text],+    updateVars :: [(Text, Text)],+    updateDryRun :: Bool,+    updateJson :: Bool,+    updateReconfigure :: Bool,+    updateForce :: Bool,+    updateRunAllCommands :: Bool,+    updateNoCommands :: Bool,+    updateCommit :: Bool,+    updateCommitMessage :: Maybe Text+  }+  deriving stock (Eq, Show, Generic)+ data RemoveOpts = RemoveOpts   { removeModule :: ModuleName,     removeDryRun :: Bool,@@ -266,7 +293,7 @@     -- 'Seihou.CLI.Migrate.runMigrate' against the *current project*     -- (cwd), if and only if that module is applied locally. Default     -- 'False'; the unset path emits a one-line advisory pointing the-    -- user at @seihou migrate@ when migrations would be pending.+    -- user at @seihou update@ when migrations would be pending.     upgradeWithMigrations :: Bool   }   deriving stock (Eq, Show, Generic)@@ -281,7 +308,8 @@ data AssistOpts = AssistOpts   { assistPrompt :: Maybe Text,     assistProvider :: Maybe Text,-    assistModel :: Maybe Text+    assistModel :: Maybe Text,+    assistEffort :: Maybe Text   }   deriving stock (Eq, Show, Generic) @@ -289,14 +317,16 @@   { bootstrapPrompt :: Maybe Text,     bootstrapRepo :: Bool,     bootstrapProvider :: Maybe Text,-    bootstrapModel :: Maybe Text+    bootstrapModel :: Maybe Text,+    bootstrapEffort :: Maybe Text   }   deriving stock (Eq, Show, Generic)  data SetupOpts = SetupOpts   { setupPrompt :: Maybe Text,     setupProvider :: Maybe Text,-    setupModel :: Maybe Text+    setupModel :: Maybe Text,+    setupEffort :: Maybe Text   }   deriving stock (Eq, Show, Generic) @@ -310,10 +340,27 @@     runBlueprintVerbose :: Bool,     runBlueprintForce :: Bool,     runBlueprintProvider :: Maybe Text,-    runBlueprintModel :: Maybe Text+    runBlueprintModel :: Maybe Text,+    runBlueprintEffort :: Maybe Text   }   deriving stock (Eq, Show, Generic) +data BlueprintMigrationOpts = BlueprintMigrationOpts+  { migrateBlueprintName :: ModuleName,+    migrateBlueprintFrom :: Text,+    migrateBlueprintTo :: Text,+    migrateBlueprintPrompt :: Maybe Text,+    migrateBlueprintVars :: [(Text, Text)],+    migrateBlueprintNamespace :: Maybe Text,+    migrateBlueprintContext :: Maybe Text,+    migrateBlueprintVerbose :: Bool,+    migrateBlueprintRerun :: Bool,+    migrateBlueprintProvider :: Maybe Text,+    migrateBlueprintModel :: Maybe Text,+    migrateBlueprintEffort :: Maybe Text+  }+  deriving stock (Eq, Show, Generic)+ data PromptCommand   = PromptRun PromptRunOpts   deriving stock (Eq, Show, Generic)@@ -327,7 +374,8 @@     runPromptVerbose :: Bool,     runPromptDebug :: Bool,     runPromptProvider :: Maybe Text,-    runPromptModel :: Maybe Text+    runPromptModel :: Maybe Text,+    runPromptEffort :: Maybe Text   }   deriving stock (Eq, Show, Generic) @@ -356,6 +404,7 @@         vsep           [ pretty ("seihou help          # list all help topics" :: String),             pretty ("seihou help modules  # how modules work" :: String),+            pretty ("seihou help update   # safely update recorded project applications" :: String),             pretty ("seihou help migrations  # apply author-declared migrations between versions" :: String)           ],       mempty,@@ -367,6 +416,7 @@   hsubparser     ( command "init" initInfo         <> command "run" runInfo+        <> command "update" updateInfo         <> command "remove" removeInfo         <> command "status" statusInfo         <> command "diff" diffInfo@@ -440,12 +490,13 @@   info     (runParser <**> helper)     ( fullDesc-        <> progDesc "Run modules to generate a project"+        <> progDesc "Apply a module initially or reconfigure it explicitly"         <> footerDoc           ( Just $               vsep-                [ pretty ("Loads the specified module and its dependencies, resolves all variables," :: String),-                  pretty ("compiles a generation plan, and executes it in the current directory." :: String),+                [ pretty ("Applies the specified module for the first time, or deliberately" :: String),+                  pretty ("reconfigures it. Variables are resolved and a generation plan executes" :: String),+                  pretty ("in the current directory." :: String),                   line,                   pretty ("Compose multiple modules with -m/--module (repeatable). Override" :: String),                   pretty ("variables with --var KEY=VALUE (repeatable). Use --dry-run to preview" :: String),@@ -458,8 +509,9 @@                   pretty ("If an applied module's installed copy has advanced past the manifest's" :: String),                   pretty ("recorded version and ships migrations that move project files," :: String),                   pretty ("'seihou run' refuses to proceed (so it never writes new templates into" :: String),-                  pretty ("paths a migration would have moved). Run 'seihou migrate <module>'" :: String),-                  pretty ("first, or pass --with-migrations to apply pending chains in-band." :: String),+                  pretty ("paths a migration would have moved). For routine source updates, run" :: String),+                  pretty ("'seihou update <target>'. Use migrate or --with-migrations for focused" :: String),+                  pretty ("recovery and explicit reconfiguration." :: String),                   line,                   pretty ("Examples:" :: String),                   indent 2 $@@ -474,6 +526,33 @@           )     ) +updateInfo :: ParserInfo Command+updateInfo =+  info+    (updateParser <**> helper)+    ( fullDesc+        <> progDesc "Update recorded project applications safely"+        <> footerDoc+          ( Just $+              vsep+                [ pretty ("Reconciles recorded module and recipe applications with newer source" :: String),+                  pretty ("content. Saved inputs are reused, migrations are included automatically," :: String),+                  pretty ("user edits are three-way merged, and unchanged commands are skipped." :: String),+                  line,+                  pretty ("With no TARGET, updates every recorded application in manifest order." :: String),+                  pretty ("Use --dry-run to preview or --json for a non-interactive machine result." :: String),+                  pretty ("--force accepts generated conflict content but retains edited orphans." :: String),+                  line,+                  indent 2 $+                    vsep+                      [ pretty ("seihou update" :: String),+                        pretty ("seihou update master-plan --dry-run" :: String),+                        pretty ("seihou update master-plan --force --commit" :: String)+                      ]+                ]+          )+    )+ removeInfo :: ParserInfo Command removeInfo =   info@@ -584,7 +663,8 @@                   line,                   pretty ("When an applied module's installed copy has advanced past the manifest's" :: String),                   pretty ("recorded version, status reports the pending migration count under that" :: String),-                  pretty ("module's line; run 'seihou migrate <module>' to apply them." :: String)+                  pretty ("module's line. Recorded applications recommend 'seihou update <target>';" :: String),+                  pretty ("manual 'seihou migrate <module>' remains available for focused recovery." :: String)                 ]           )     )@@ -754,6 +834,42 @@             <> help "Apply any pending module migrations before the run plan; without this, 'seihou run' refuses when migrations are pending"         ) +updateParser :: Parser Command+updateParser =+  fmap Update $+    makeUpdateOpts+      <$> many (argument (T.pack <$> str) (metavar "TARGET" <> help "Recorded application target or contained module (repeatable; default: all)"))+      <*> many+        ( option+            varPair+            (long "var" <> metavar "KEY=VALUE" <> help "Variable override (repeatable)")+        )+      <*> switch (long "dry-run" <> help "Show the complete update plan without modifying managed state")+      <*> switch (long "json" <> help "Emit one JSON document and disable prompts")+      <*> switch (long "reconfigure" <> help "Ignore saved inputs and resolve them again")+      <*> switch (long "force" <> help "Use generated content for safe conflicts and retain edited orphans")+      <*> updateCommandFlags+      <*> switch (long "commit" <> help "Commit successfully updated managed paths")+      <*> optional (option (T.pack <$> str) (long "commit-message" <> metavar "MSG" <> help "Custom commit message (implies --commit)"))+  where+    makeUpdateOpts targets vars dryRun json reconfigure force (runAll, noCommands) commit commitMessage =+      UpdateOpts+        { updateTargets = targets,+          updateVars = vars,+          updateDryRun = dryRun,+          updateJson = json,+          updateReconfigure = reconfigure,+          updateForce = force,+          updateRunAllCommands = runAll,+          updateNoCommands = noCommands,+          updateCommit = commit,+          updateCommitMessage = commitMessage+        }+    updateCommandFlags =+      flag' (True, False) (long "run-all-commands" <> help "Run every generated command, including unchanged ones")+        <|> flag' (False, True) (long "no-commands" <> help "Skip every generated command")+        <|> pure (False, False)+ varsParser :: Parser Command varsParser =   fmap Vars $@@ -1066,10 +1182,9 @@                   pretty ("Example:" :: String),                   indent 2 $ pretty ("seihou outdated" :: String),                   line,-                  pretty ("Run 'seihou upgrade' to apply available updates. If a module ships" :: String),-                  pretty ("migrations, 'seihou upgrade' will surface them via an advisory; run" :: String),-                  pretty ("'seihou migrate <module>' (or 'seihou upgrade --with-migrations')" :: String),-                  pretty ("to apply them to the current project." :: String)+                  pretty ("Run 'seihou update' in an applied project to fetch and reconcile" :: String),+                  pretty ("available versions. Use 'seihou upgrade' only to refresh the shared" :: String),+                  pretty ("installed cache, or 'seihou migrate <module>' for focused recovery." :: String)                 ]           )     )@@ -1085,7 +1200,7 @@   info     (upgradeParser <**> helper)     ( fullDesc-        <> progDesc "Upgrade installed modules to latest versions"+        <> progDesc "Refresh shared installed-cache sources only"         <> footerDoc (Just upgradeFooter)     ) @@ -1105,8 +1220,9 @@ upgradeFooter :: Doc upgradeFooter =   vsep-    [ pretty ("Upgrades installed modules to the latest version available from their" :: String),+    [ pretty ("Refreshes installed-cache modules to the latest version available from their" :: String),       pretty ("source repository. Only modules installed via 'seihou install' are checked." :: String),+      pretty ("It does not reconcile the current project; use 'seihou update' for that." :: String),       pretty ("Modules without version information are upgraded by default." :: String),       pretty ("Use --skip-unversioned to skip them." :: String),       line,@@ -1128,7 +1244,7 @@         vsep           [ pretty ("Newer module versions may declare migrations that move project files" :: String),             pretty ("when applied. By default 'seihou upgrade' does not run them — it only" :: String),-            pretty ("prints an advisory pointing at 'seihou migrate <module>'. Pass" :: String),+            pretty ("prints an advisory pointing project users at 'seihou update'. Pass" :: String),             pretty ("--with-migrations to run them as part of the upgrade." :: String)           ]     ]@@ -1372,6 +1488,7 @@                 [ pretty ("Agent subcommands provide AI-assisted workflows powered by" :: String),                   pretty ("configurable CLI or API providers. Use --provider to select claude-cli, codex-cli," :: String),                   pretty ("anthropic, or openai, and --model for a provider-specific model." :: String),+                  pretty ("Run 'seihou agent models' to list known model choices." :: String),                   line,                   pretty ("Use --debug with any subcommand to print the resolved system" :: String),                   pretty ("prompt without contacting the configured provider." :: String),@@ -1382,7 +1499,9 @@                       [ pretty ("assist      AI-assisted template authoring prompt" :: String),                         pretty ("bootstrap   Bootstrap a new module or multi-module repo" :: String),                         pretty ("setup       Guided project setup: configure, run, and commit" :: String),-                        pretty ("run         Run an agent-driven blueprint" :: String)+                        pretty ("run         Run an agent-driven blueprint" :: String),+                        pretty ("migrate     Run ordered library-upgrade blueprint migrations" :: String),+                        pretty ("models      List known agent models" :: String)                       ]                 ]           )@@ -1406,9 +1525,10 @@             (T.pack <$> str)             ( long "model"                 <> metavar "MODEL"-                <> help "Agent model name or provider-specific model alias"+                <> help "Agent model name or provider-specific alias; 'seihou agent models' lists known choices"             )         )+      <*> effortOption       <*> agentCommandParser  agentCommandParser :: Parser AgentCommand@@ -1418,6 +1538,9 @@         <> command "bootstrap" agentBootstrapInfo         <> command "setup" agentSetupInfo         <> command "run" agentRunInfo+        <> command "migrate" agentMigrateInfo+        <> command "models" agentModelsInfo+        <> command "config" agentConfigInfo     )  agentAssistInfo :: ParserInfo AgentCommand@@ -1457,6 +1580,7 @@       <$> optional (argument (T.pack <$> str) (metavar "PROMPT" <> help "Initial prompt describing what you want to do"))       <*> providerOption       <*> modelOption+      <*> effortOption  agentBootstrapInfo :: ParserInfo AgentCommand agentBootstrapInfo =@@ -1494,6 +1618,7 @@       <*> switch (long "repo" <> help "Bootstrap a multi-module repository with registry")       <*> providerOption       <*> modelOption+      <*> effortOption  agentSetupInfo :: ParserInfo AgentCommand agentSetupInfo =@@ -1530,6 +1655,7 @@       <$> optional (argument (T.pack <$> str) (metavar "PROMPT" <> help "Description of what you want to set up"))       <*> providerOption       <*> modelOption+      <*> effortOption  agentRunInfo :: ParserInfo AgentCommand agentRunInfo =@@ -1584,7 +1710,112 @@       <*> switch (long "force" <> help "Auto-resolve baseline conflicts (accept new files)")       <*> providerOption       <*> modelOption+      <*> effortOption +agentMigrateInfo :: ParserInfo AgentCommand+agentMigrateInfo =+  info+    (agentMigrateParser <**> helper)+    ( fullDesc+        <> progDesc "Run ordered agent-guided migrations declared by a blueprint"+        <> footerDoc+          ( Just $+              vsep+                [ pretty ("Selects the blueprint migrations inside the explicit version window," :: String),+                  pretty ("runs one provider interaction per edge, and records each successful" :: String),+                  pretty ("edge so an interrupted chain resumes without repeating completed work." :: String),+                  line,+                  pretty ("Versions must be dotted numeric values. Gaps are allowed. Pass --rerun" :: String),+                  pretty ("to ignore matching receipts. Parent --debug prints every pending prompt" :: String),+                  pretty ("without contacting a provider or changing the manifest." :: String),+                  line,+                  pretty ("Examples:" :: String),+                  indent 2 $+                    vsep+                      [ pretty ("seihou agent migrate my-library --from 1.0.0 --to 3.0.0" :: String),+                        pretty ("seihou agent migrate my-library --from 1 --to 3 --rerun" :: String),+                        pretty ("seihou agent --debug migrate my-library --from 1.0.0 --to 3.0.0" :: String)+                      ]+                ]+          )+    )++agentMigrateParser :: Parser AgentCommand+agentMigrateParser =+  fmap AgentMigrate $+    BlueprintMigrationOpts+      <$> argument moduleNameReader (metavar "BLUEPRINT" <> help "Name of the blueprint containing migrations")+      <*> option (T.pack <$> str) (long "from" <> metavar "VERSION" <> help "Currently used library version (dotted numeric)")+      <*> option (T.pack <$> str) (long "to" <> metavar "VERSION" <> help "Desired library version (dotted numeric)")+      <*> optional (argument (T.pack <$> str) (metavar "PROMPT" <> help "Optional initial user instruction for each migration session"))+      <*> many+        ( option+            varPair+            (long "var" <> metavar "KEY=VALUE" <> help "Variable override (repeatable)")+        )+      <*> optional (option (T.pack <$> str) (long "namespace" <> metavar "NS" <> help "Override namespace for config lookup"))+      <*> optional (option (T.pack <$> str) (long "context" <> short 'c' <> metavar "CTX" <> help "Override context for config lookup"))+      <*> switch (long "verbose" <> short 'v' <> help "Show detailed progress messages")+      <*> switch (long "rerun" <> help "Run matching migrations even when a receipt already exists")+      <*> providerOption+      <*> modelOption+      <*> effortOption++agentModelsInfo :: ParserInfo AgentCommand+agentModelsInfo =+  info+    (agentModelsParser <**> helper)+    ( fullDesc+        <> progDesc "List known agent models"+        <> footerDoc+          ( Just $+              vsep+                [ pretty ("Lists the Anthropic and OpenAI models in Seihou's compiled Baikai catalog." :: String),+                  pretty ("Use --provider to filter by an API or compatible local CLI provider." :: String),+                  line,+                  pretty ("The catalog is a discovery aid, not validation. Provider-native aliases" :: String),+                  pretty ("and custom model identifiers remain accepted by --model." :: String),+                  line,+                  pretty ("Examples:" :: String),+                  indent 2 $+                    vsep+                      [ pretty ("seihou agent models" :: String),+                        pretty ("seihou agent models --provider openai" :: String),+                        pretty ("seihou agent --provider claude-cli models" :: String)+                      ]+                ]+          )+    )++agentModelsParser :: Parser AgentCommand+agentModelsParser =+  AgentModels . AgentModelsOpts <$> providerOption++agentConfigInfo :: ParserInfo AgentCommand+agentConfigInfo =+  info+    (pure AgentConfigShow <**> helper)+    ( fullDesc+        <> progDesc "Show the resolved provider and model for each agent command"+        <> footerDoc+          ( Just $+              vsep+                [ pretty ("Prints the provider and model that each agent command resolves to," :: String),+                  pretty ("labelling the source of every value: a config scope and key, an" :: String),+                  pretty ("environment variable, or the built-in default. Read-only; set values" :: String),+                  pretty ("with `seihou config set agent.<command>.model ...`." :: String),+                  line,+                  pretty ("Examples:" :: String),+                  indent 2 $+                    vsep+                      [ pretty ("seihou agent config" :: String),+                        pretty ("seihou config set agent.assist.model gpt-5-mini --global" :: String),+                        pretty ("seihou config set agent.run.model claude-opus-4-8" :: String)+                      ]+                ]+          )+    )+ promptInfo :: ParserInfo Command promptInfo =   info@@ -1664,6 +1895,7 @@       <*> switch (long "debug" <> help "Print the rendered prompt and exit")       <*> providerOption       <*> modelOption+      <*> effortOption  helpCmdInfo :: ParserInfo Command helpCmdInfo =@@ -1778,5 +2010,15 @@       (T.pack <$> str)       ( long "model"           <> metavar "MODEL"-          <> help "Agent model name or provider-specific model alias"+          <> help "Agent model name or provider-specific alias; 'seihou agent models' lists known choices"+      )++effortOption :: Parser (Maybe Text)+effortOption =+  optional $+    option+      (T.pack <$> str)+      ( long "effort"+          <> metavar "LEVEL"+          <> help "Reasoning effort: minimal, low, medium, high, xhigh, or max"       )
src-exe/Seihou/CLI/Help.hs view
@@ -38,7 +38,8 @@     HelpTopic "kit" "Manage Claude Code and Codex skills and subagents" kitContent,     HelpTopic "migrations" "Migrating a project between module versions" migrationsContent,     HelpTopic "prompts" "Reusable agent-session prompt artifacts" promptsContent,-    HelpTopic "templating" "Placeholder substitution, {{#if}} blocks, and patterns" templatingContent+    HelpTopic "templating" "Placeholder substitution, {{#if}} blocks, and patterns" templatingContent,+    HelpTopic "update" "Safely update recorded project applications" updateContent   ]  agentContent :: Text@@ -73,6 +74,9 @@  templatingContent :: Text templatingContent = $(embedStringFile "help/templating.md")++updateContent :: Text+updateContent = $(embedStringFile "help/update.md")  helpCommandParser :: Parser HelpCommand helpCommandParser =
src-exe/Seihou/CLI/Install.hs view
@@ -229,7 +229,7 @@     Right _ -> pure ()   TIO.putStrLn "  Validated blueprint definition" -  let bpVersion = case bp of Blueprint _ v _ _ _ _ _ _ _ _ -> v+  let bpVersion = case bp of Blueprint _ v _ _ _ _ _ _ _ _ _ -> v   installModuleDir rootDir name source Nothing bpVersion []   TIO.putStrLn ""   TIO.putStrLn $ "Blueprint available as: " <> T.pack name@@ -458,7 +458,7 @@                     logError $ "  failed to load " <> entry.name.unModuleName <> ": " <> T.pack (show err)                   pure False                 Right bp -> do-                  let bpVersion = case bp of Blueprint _ v _ _ _ _ _ _ _ _ -> v+                  let bpVersion = case bp of Blueprint _ v _ _ _ _ _ _ _ _ _ -> v                       ver = entry.version <|> bpVersion                   installModuleDir entryDir name source (Just repoName) ver entry.tags                   TIO.putStrLn $ "    Installed blueprint as: " <> T.pack name
src-exe/Seihou/CLI/Run.hs view
@@ -3,7 +3,8 @@   ) where -import Control.Monad (foldM, unless, when)+import Control.Exception (IOException, displayException, try)+import Control.Monad (foldM, forM_, unless, when) import Data.Map.Strict qualified as Map import Data.Maybe (fromMaybe, isJust) import Data.Set qualified as Set@@ -11,6 +12,16 @@ import Data.Text.IO qualified as TIO import Data.Time (UTCTime) import Data.Time.Clock (getCurrentTime)+import Seihou.CLI.CommandExecution+  ( CommandDisposition (..),+    CommandExecutionError (..),+    CommandPlan (..),+    CommandPolicy (..),+    PlannedCommand (..),+    executeCommandPlanWithOutput,+    finalizeCommandReceipts,+    planCommands,+  ) import Seihou.CLI.Commands (RunOpts (..)) import Seihou.CLI.CommitMessage (generateCommitMessage) import Seihou.CLI.Git (gitAdd, gitCheckIgnore, gitCommit, gitDiffCached, isGitRepo)@@ -31,12 +42,15 @@ import Seihou.Composition.Plan (compileComposedPlan) import Seihou.Composition.Recipe (expandRecipe) import Seihou.Composition.Resolve (loadComposition, resolveWithPrompts)+import Seihou.Core.Application (attachApplication, buildAppliedComposition, mkApplicationId, replaceAppliedComposition) import Seihou.Core.Context (resolveContext) import Seihou.Core.Migration (MigrationPlan (..)) import Seihou.Core.Module (defaultSearchPaths, discoverRunnable) import Seihou.Core.Types import Seihou.Core.Variable (diagnoseResolution) import Seihou.Core.Version (renderVersion)+import Seihou.Effect.BaselineStore (pruneBaselines)+import Seihou.Effect.BaselineStoreInterp (runBaselineStore) import Seihou.Effect.ConfigReader (readContextConfig, readGlobalConfig, readLocalConfig, readNamespaceConfig) import Seihou.Effect.ConfigReaderInterp (runConfigReader) import Seihou.Effect.ConfigWriterInterp (runConfigWriter)@@ -47,8 +61,8 @@ import Seihou.Effect.Logger (logDebug, logError, logInfo, logWarn) import Seihou.Effect.ManifestStore (readManifest, writeManifest) import Seihou.Effect.ManifestStoreInterp (runManifestStore)-import Seihou.Effect.Process (runProcess) import Seihou.Effect.ProcessInterp (runProcessIO)+import Seihou.Engine.Baseline (manifestBaselineRefs, recordGeneratedBaselines) import Seihou.Engine.Conflict (resolveConflicts) import Seihou.Engine.Diff (computeDiff) import Seihou.Engine.Execute (executePlan)@@ -91,10 +105,10 @@    -- 0b. Recipe detection: check if the name resolves to a recipe or a module   searchPaths <- defaultSearchPaths-  (primaryName, allAdditional, recipeOverrides, recipeInfo) <- do+  (primaryName, allAdditional, recipeOverrides, recipeInfo, targetInfo) <- do     runnableResult <- discoverRunnable searchPaths modName     case runnableResult of-      Right (RunnableRecipe recipe _recipeDir) -> do+      Right (RunnableRecipe recipe recipeDir) -> do         case expandRecipe recipe of           Left errs -> do             logIO level $@@ -108,9 +122,21 @@             logIO level $               logInfo $                 "Recipe '" <> recipe.name.unRecipeName <> "' expanding to " <> T.pack (show (length recipe.modules)) <> " modules"-            pure (primary, recipeAdditional ++ additional, overrides, Just (recipe.name, recipe.version))-      Right (RunnableModule _ _) ->-        pure (modName, additional, Map.empty, Nothing)+            pure+              ( primary,+                recipeAdditional ++ additional,+                overrides,+                Just (recipe.name, recipe.version),+                (AppliedRecipeTarget recipe.name, recipeDir, recipe.version)+              )+      Right (RunnableModule modul moduleDir) ->+        pure+          ( modName,+            additional,+            Map.empty,+            Nothing,+            (AppliedModuleTarget modName, moduleDir, modul.version)+          )       Right (RunnableBlueprint _b _blueprintDir) -> do         -- Use the user-typed name (modName) rather than the blueprint's         -- declared name. Discovery resolves by directory name; the@@ -120,7 +146,13 @@         exitFailure       Left _ ->         -- Discovery failed — let loadComposition handle the error with its detailed message-        pure (modName, additional, Map.empty, Nothing)+        pure+          ( modName,+            additional,+            Map.empty,+            Nothing,+            (AppliedModuleTarget modName, "", Nothing)+          )    -- 1. Load all modules in the composition (primary + additional + transitive deps)   compositionResult <- loadComposition searchPaths primaryName allAdditional@@ -214,6 +246,7 @@   -- 6. Compute diff (shared by dry-run, --diff, and execution paths)   now <- getCurrentTime   let manifestPath = ".seihou" </> "manifest.json"+      baselineDir = ".seihou" </> "baselines"       planned =         [(dest, content, modName, Nothing) | WriteFileOp dest content _ <- opsFiltered]           ++ [(dest, content, mName, Just pOp) | PatchFileOp dest content pOp _ mName <- opsFiltered]@@ -240,6 +273,26 @@   manifest <-     handlePendingMigrations level runOpts manifestPath initialManifest pendings +  -- Commands are planned against the previously accepted receipts for this+  -- exact top-level application. Ordinary run deliberately remains run-all;+  -- --no-commands disables execution while retaining matching old receipts.+  let (appliedTarget, targetSource, targetVersion) = targetInfo+      currentApplicationId = mkApplicationId appliedTarget additional+      priorCommandReceipts =+        case [ application.commandReceipts+             | application <- manifest.applications,+               application.applicationId == currentApplicationId+             ] of+          receipts : _ -> receipts+          [] -> Map.empty+      commandPolicy =+        if runOpts.runNoCommands+          then DisableCommands+          else RunAllCommands+      commandPlan = planCommands commandPolicy priorCommandReceipts ops+      candidateCommandReceipts =+        finalizeCommandReceipts commandPlan [] priorCommandReceipts+   -- 6c. Compute the diff against the (possibly post-migration) manifest.   diff <- runEff $ runFilesystem $ runManifestStore manifestPath $ do     -- Diff needs every name that could own a manifest file. Each@@ -301,7 +354,9 @@                                 { hash = c.diskHash,                                   moduleName = c.moduleName,                                   strategy = Template,-                                  generatedAt = now+                                  generatedAt = now,+                                  baseline = Nothing,+                                  applicationIds = mempty                                 }                         )                       | (c, KeepCurrent) <- conflictResolved@@ -310,34 +365,93 @@                   excludePaths = Set.fromList (Map.keys keepRecords ++ skipPaths)                   opsForExec = filter (not . opTargetsPath excludePaths) opsFiltered -              -- Execute the plan (excluding kept/skipped files)-              runEff $ runFilesystem $ runManifestStore manifestPath $ do-                recs <- executePlan "" opsForExec ownerMap modName now+              -- Execute the plan (excluding kept/skipped files), capture the+              -- exact post-execution baselines, and only then publish the+              -- manifest that references those blobs.+              generationAttempt <-+                try @IOException $+                  runEff $+                    runFilesystem $+                      runBaselineStore baselineDir $+                        runManifestStore manifestPath $ do+                          recs <- executePlan "" opsForExec ownerMap modName now+                          baselineResult <- recordGeneratedBaselines "" recs+                          case baselineResult of+                            Left err -> pure (Left err)+                            Right baselineRecords -> do+                              -- Build updated manifest with all composed modules.+                              let orphanedPaths = map (.path) diff.orphaned+                                  cleanedFiles = foldr Map.delete manifest.files orphanedPaths+                                  allModuleEntries = updateAllModules manifest.modules modulesInOrder now+                                  allResolvedVals =+                                    Map.unions+                                      [Map.map (.value) vs | vs <- Map.elems resolved]+                                  appliedRecipe = case recipeInfo of+                                    Just (rName, rVersion) ->+                                      Just AppliedRecipe {name = rName, recipeVersion = rVersion, appliedAt = now}+                                    Nothing -> manifest.recipe+                                  appliedCompositionWithoutReceipts =+                                    buildAppliedComposition+                                      appliedTarget+                                      targetSource+                                      targetVersion+                                      additional+                                      (Just namespace)+                                      context+                                      modulesInOrder+                                      resolved+                                      now+                                  appliedComposition =+                                    appliedCompositionWithoutReceipts+                                      { commandReceipts = candidateCommandReceipts+                                      }+                                  applicationDestinations =+                                    Set.fromList [path | Just path <- map operationDestination opsFiltered]+                                  combinedFiles = Map.unions [baselineRecords, keepRecords, cleanedFiles]+                                  ownedFiles =+                                    Map.mapWithKey+                                      ( \path record ->+                                          if Set.member path applicationDestinations+                                            then attachApplication appliedComposition.applicationId (Map.lookup path manifest.files) record+                                            else record+                                      )+                                      combinedFiles+                                  newManifest =+                                    Manifest+                                      { version = currentManifestVersion,+                                        genAt = now,+                                        modules = allModuleEntries,+                                        vars = Map.union (Map.map varValueToText allResolvedVals) manifest.vars,+                                        files = ownedFiles,+                                        applications = replaceAppliedComposition appliedComposition manifest.applications,+                                        recipe = appliedRecipe,+                                        blueprint = manifest.blueprint,+                                        blueprintMigrations = manifest.blueprintMigrations+                                      }+                              writeManifest newManifest+                              pure (Right newManifest) -                -- Build updated manifest with all composed modules-                let orphanedPaths = map (.path) diff.orphaned-                    cleanedFiles = foldr Map.delete manifest.files orphanedPaths-                    allModuleEntries = updateAllModules manifest.modules modulesInOrder now-                    allResolvedVals =-                      Map.unions-                        [Map.map (.value) vs | vs <- Map.elems resolved]-                    appliedRecipe = case recipeInfo of-                      Just (rName, rVersion) ->-                        Just AppliedRecipe {name = rName, recipeVersion = rVersion, appliedAt = now}-                      Nothing -> manifest.recipe-                    newManifest =-                      Manifest-                        { version = currentManifestVersion,-                          genAt = now,-                          modules = allModuleEntries,-                          vars = Map.union (Map.map varValueToText allResolvedVals) manifest.vars,-                          files = Map.unions [recs, keepRecords, cleanedFiles],-                          recipe = appliedRecipe,-                          blueprint = manifest.blueprint-                        }+              newManifest <- case generationAttempt of+                Left err -> do+                  logIO level $ logError $ "Error applying files or storing generated baselines: " <> T.pack (displayException err)+                  exitFailure+                Right (Left err) -> do+                  logIO level $ logError $ "Error storing generated baselines: " <> T.pack (show err)+                  exitFailure+                Right (Right saved) -> pure saved -                -- Save manifest-                writeManifest newManifest+              -- Pruning is safe only after the new manifest is durable. A+              -- pruning failure cannot invalidate the successful generation.+              pruneAttempt <-+                try @IOException $+                  runEff $+                    runFilesystem $+                      runBaselineStore baselineDir $+                        pruneBaselines (manifestBaselineRefs newManifest)+              case pruneAttempt of+                Left err ->+                  logIO level $ logWarn $ "Warning: could not prune generated baselines: " <> T.pack (displayException err)+                Right _ -> pure ()                -- Report results               let nNew = length diff.new@@ -351,12 +465,60 @@                   <> T.pack (show nUnch)                   <> " unchanged." +              -- Execute commands after file generation. The command library+              -- returns candidate receipts only when the entire phase+              -- succeeds, so a failed run leaves the candidate manifest with+              -- no newly-minted success evidence.+              forM_ commandPlan.commands $ \planned ->+                when (planned.disposition == CommandWillRun) $+                  logIO level (logDebug $ "  run  " <> plannedCommandText planned)+              commandResult <-+                runEff $+                  runProcessIO $+                    executeCommandPlanWithOutput+                      now+                      ( \_ commandStdout _ ->+                          when (not (T.null commandStdout)) (liftIO $ TIO.putStr commandStdout)+                      )+                      commandPlan+              completedReceipts <- case commandResult of+                Right receipts -> pure receipts+                Left commandError -> do+                  when (not (T.null commandError.stdout)) $ TIO.putStr commandError.stdout+                  when (not (T.null commandError.stderr)) $ TIO.putStr commandError.stderr+                  logIO level $+                    logError $+                      "Command failed (exit "+                        <> T.pack (show commandError.exitCode)+                        <> "): "+                        <> plannedCommandText commandError.command+                  exitFailure++              let finalCommandReceipts =+                    finalizeCommandReceipts commandPlan completedReceipts priorCommandReceipts+                  receiptManifest =+                    setApplicationCommandReceipts+                      currentApplicationId+                      finalCommandReceipts+                      newManifest+              receiptWriteAttempt <-+                try @IOException $+                  runEff $+                    runFilesystem $+                      runManifestStore manifestPath $+                        writeManifest receiptManifest+              case receiptWriteAttempt of+                Left err -> do+                  logIO level $ logError $ "Commands succeeded but their receipts could not be recorded: " <> T.pack (displayException err)+                  exitFailure+                Right () -> pure ()+               -- Commit generated files if --commit or --commit-message               when (runOpts.runCommit || isJust runOpts.runCommitMessage) $ do                 let filesToStage =                       map (.path) diff.new                         ++ map (.path) diff.modified-                        ++ [manifestPath]+                        ++ [manifestPath, baselineDir]                 inGit <- runEff $ runProcessIO $ isGitRepo                 if inGit                   then do@@ -381,10 +543,6 @@                   else                     logIO level (logDebug "--commit: not inside a git repository, skipping.") -              -- Execute commands after file generation-              let commandOps = [(cmd, wd) | RunCommandOp cmd wd <- opsForExec]-              mapM_ (executeCommand level) commandOps-               -- Offer to save prompted values to local config               let prompted = collectPromptedValues resolved localMap               when (not (null prompted)) $@@ -454,7 +612,7 @@  -- | Whether an operation is a command (RunCommandOp). isCommandOp :: Operation -> Bool-isCommandOp (RunCommandOp _ _) = True+isCommandOp RunCommandOp {} = True isCommandOp _ = False  -- | Check whether an operation targets a file in the given path set.@@ -463,18 +621,32 @@ opTargetsPath paths (PatchFileOp dest _ _ _ _) = Set.member dest paths opTargetsPath _ _ = False --- | Execute a shell command via @sh -c@, printing output and halting on failure.-executeCommand :: LogLevel -> (Text, Maybe FilePath) -> IO ()-executeCommand level (cmd, workDir) = do-  logIO level (logDebug $ "  run  " <> cmd)-  (exitCode, cmdOut, cmdErr) <- runEff $ runProcessIO $ runProcess "sh" ["-c", cmd] workDir-  when (not (T.null cmdOut)) $ TIO.putStr cmdOut-  case exitCode of-    ExitSuccess -> pure ()-    ExitFailure code -> do-      when (not (T.null cmdErr)) $ TIO.putStr cmdErr-      logIO level (logError $ "Command failed (exit " <> T.pack (show code) <> "): " <> cmd)-      exitFailure+-- | Return the tracked destination produced by a file operation.+operationDestination :: Operation -> Maybe FilePath+operationDestination (WriteFileOp dest _ _) = Just dest+operationDestination (CopyFileOp _ dest) = Just dest+operationDestination (PatchFileOp dest _ _ _ _) = Just dest+operationDestination _ = Nothing++plannedCommandText :: PlannedCommand -> Text+plannedCommandText planned = case planned.operation of+  RunCommandOp {command} -> command+  _ -> "<non-command operation>"++setApplicationCommandReceipts ::+  ApplicationId ->+  Map CommandFingerprint CommandReceipt ->+  Manifest ->+  Manifest+setApplicationCommandReceipts applicationId receipts manifest =+  manifest+    { applications = map updateApplication manifest.applications+    }+  where+    updateApplication application+      | application.applicationId == applicationId =+          application {commandReceipts = receipts}+      | otherwise = application  -- | Apply the pending-migration policy. --
+ src-exe/Seihou/CLI/Update.hs view
@@ -0,0 +1,203 @@+module Seihou.CLI.Update+  ( handleUpdate,+  )+where++import Control.Monad (unless, when)+import Data.Aeson (encode, object, (.=))+import Data.ByteString.Lazy.Char8 qualified as LBS+import Data.Maybe (isJust)+import Data.Set qualified as Set+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Seihou.CLI.CommandExecution (CommandPolicy (..))+import Seihou.CLI.Commands (UpdateOpts (..))+import Seihou.CLI.CommitMessage (generateCommitMessage)+import Seihou.CLI.Git (gitAdd, gitCheckIgnore, gitCommit, gitDiffCached, isGitRepo)+import Seihou.CLI.Style (useColor)+import Seihou.CLI.Update.Interaction+  ( InteractionError (..),+    InteractionMode (..),+    forceResolveUpdatePlan,+    resolveInteractively,+  )+import Seihou.CLI.Update.Render+  ( encodeUpdateOutput,+    errorOutput,+    planOutput,+    renderUpdateHuman,+    resultOutput,+  )+import Seihou.Core.Types (ModuleName (..))+import Seihou.Effect.ProcessInterp (runProcessIO)+import Seihou.Prelude+import System.Exit (ExitCode (..), exitFailure)+import System.FilePath (isAbsolute)+import System.IO (hFlush, hIsTerminalDevice, isEOF, stderr, stdin)+import "seihou-cli" Seihou.CLI.Update qualified as Service++handleUpdate :: UpdateOpts -> IO ()+handleUpdate opts = do+  validateOptions opts+  terminal <- hIsTerminalDevice stdin+  let request = requestFromOptions terminal opts+  Service.withProjectUpdate request (handlePlanned terminal opts)++validateOptions :: UpdateOpts -> IO ()+validateOptions opts+  | opts.updateDryRun && (opts.updateCommit || isJust opts.updateCommitMessage) =+      failCli opts "invalid_options" "--commit and --commit-message cannot be used with --dry-run"+  | opts.updateRunAllCommands && opts.updateNoCommands =+      failCli opts "invalid_options" "--run-all-commands and --no-commands are mutually exclusive"+  | otherwise = pure ()++requestFromOptions :: Bool -> UpdateOpts -> Service.UpdateRequest+requestFromOptions terminal opts =+  Service.UpdateRequest+    { selection =+        if null opts.updateTargets+          then Service.AllRecordedApplications+          else Service.NamedUpdateTargets opts.updateTargets,+      varOverrides = opts.updateVars,+      reconfigure = opts.updateReconfigure,+      promptPolicy =+        if terminal && not opts.updateJson+          then Service.AllowPrompts+          else Service.ForbidPrompts,+      commandPolicy =+        if opts.updateRunAllCommands+          then RunAllCommands+          else if opts.updateNoCommands then DisableCommands else RunChangedCommands,+      dryRun = opts.updateDryRun+    }++handlePlanned :: Bool -> UpdateOpts -> Either Service.UpdateError Service.UpdatePlan -> IO ()+handlePlanned _ opts (Left err) = do+  if opts.updateJson+    then LBS.putStrLn (encodeUpdateOutput (errorOutput err))+    else TIO.hPutStr stderr (renderUpdateHuman False (errorOutput err))+  exitFailure+handlePlanned terminal opts (Right originalPlan) = do+  forced <-+    if opts.updateForce+      then either (failInteraction opts) pure (forceResolveUpdatePlan originalPlan)+      else pure originalPlan+  resolved <-+    resolveInteractively+      (if terminal && not opts.updateJson then Interactive else NonInteractive)+      forced+      >>= either (failInteraction opts) pure+  color <- useColor+  if Service.isUpdateNoOp resolved+    then+      if opts.updateJson+        then LBS.putStrLn (encodeUpdateOutput (planOutput resolved))+        else TIO.putStrLn "Already up to date."+    else+      if opts.updateDryRun+        then emitPlan color opts resolved+        else do+          unless opts.updateJson $ TIO.putStr (renderUpdateHuman color (planOutput resolved))+          accepted <- if opts.updateJson then pure True else confirmApply terminal+          if not accepted+            then TIO.hPutStrLn stderr "Update cancelled; no managed state was changed."+            else do+              applied <- Service.applyProjectUpdate resolved+              case applied of+                Left err -> handlePlanned terminal opts (Left err)+                Right result -> do+                  if opts.updateJson+                    then LBS.putStrLn (encodeUpdateOutput (resultOutput result))+                    else TIO.putStr (renderUpdateHuman color (resultOutput result))+                  when (opts.updateCommit || isJust opts.updateCommitMessage) $ do+                    committed <- commitUpdate opts result+                    case committed of+                      Left err -> do+                        TIO.hPutStrLn stderr ("Update succeeded, but git commit failed: " <> err)+                        exitFailure+                      Right () -> pure ()++emitPlan :: Bool -> UpdateOpts -> Service.UpdatePlan -> IO ()+emitPlan color opts plan =+  if opts.updateJson+    then LBS.putStrLn (encodeUpdateOutput (planOutput plan))+    else TIO.putStr (renderUpdateHuman color (planOutput plan))++confirmApply :: Bool -> IO Bool+confirmApply False = pure False+confirmApply True = loop+  where+    loop = do+      TIO.hPutStr stderr "Apply? [Y/n] "+      hFlush stderr+      eof <- isEOF+      if eof+        then pure False+        else do+          answer <- T.toLower . T.strip <$> TIO.getLine+          case answer of+            "" -> pure True+            "y" -> pure True+            "yes" -> pure True+            "n" -> pure False+            "no" -> pure False+            _ -> do+              TIO.hPutStrLn stderr "Please answer yes or no."+              loop++commitUpdate :: UpdateOpts -> Service.UpdateResult -> IO (Either Text ())+commitUpdate opts result = do+  let candidates = filter (not . isAbsolute) (Set.toAscList result.touchedPaths)+  inRepo <- runEff $ runProcessIO isGitRepo+  if not inRepo+    then pure (Right ())+    else do+      ignored <- runEff $ runProcessIO $ gitCheckIgnore candidates+      let staged = filter (`notElem` ignored) candidates+      if null staged+        then pure (Right ())+        else do+          (addExit, _, addErr) <- runEff $ runProcessIO $ gitAdd staged+          case addExit of+            ExitFailure _ -> pure (Left (T.strip addErr))+            ExitSuccess -> do+              message <- case opts.updateCommitMessage of+                Just custom -> pure custom+                Nothing -> do+                  diff <- runEff $ runProcessIO gitDiffCached+                  let modules = map (ModuleName . (.name)) result.versions+                  generateCommitMessage modules diff+              (commitExit, _, commitErr) <- runEff $ runProcessIO $ gitCommit message+              pure $ case commitExit of+                ExitSuccess -> Right ()+                ExitFailure _ -> Left (T.strip commitErr)++failInteraction :: UpdateOpts -> InteractionError -> IO a+failInteraction opts err = case err of+  InteractionRequired paths ->+    failCli+      opts+      "unresolved_conflicts"+      ( "Unresolved paths require an interactive terminal or an applicable --force choice: "+          <> T.intercalate ", " (map T.pack (Set.toAscList paths))+      )+  InteractionAborted path ->+    failCli opts "update_aborted" ("Resolution aborted for " <> T.pack path)+  InteractionResolutionFailed inner ->+    failCli opts "resolution_failed" (T.pack (show inner))+  InteractionInputFailed message ->+    failCli opts "input_failed" message++failCli :: UpdateOpts -> Text -> Text -> IO a+failCli opts code message = do+  if opts.updateJson+    then+      LBS.putStrLn $+        encode $+          object+            [ "schemaVersion" .= (1 :: Int),+              "outcome" .= ("error" :: Text),+              "error" .= object ["code" .= code, "message" .= message]+            ]+    else TIO.hPutStrLn stderr ("Update failed [" <> code <> "]: " <> message)+  exitFailure
src-exe/Seihou/CLI/Upgrade.hs view
@@ -326,9 +326,7 @@           <> renderVersion plan.planFrom           <> " → "           <> renderVersion plan.planTo-          <> "); run 'seihou migrate "-          <> name-          <> "'"+          <> "); run 'seihou update' to reconcile the recorded project application"   TIO.putStrLn $ if colorEnabled then yellow msg else msg  -- | Run a migration for a single module. Reads the manifest fresh so
src/Seihou/CLI/AgentCompletion.hs view
@@ -5,8 +5,11 @@     AgentModelConfig (..),     AgentCompletionRequest (..),     defaultAgentModelConfig,+    defaultModelForProvider,     providerFromText,     providerToText,+    effortFromText,+    effortToText,     buildAgentCompletionRequest,     buildBaikaiModel,     runAgentCompletion,@@ -15,10 +18,12 @@ where  import Baikai qualified+import Baikai.Options qualified as BaikaiOptions import Baikai.Provider.Claude.Api qualified as ClaudeApi import Baikai.Provider.Claude.Cli qualified as ClaudeCli import Baikai.Provider.OpenAI.Api qualified as OpenAIApi import Baikai.Provider.OpenAI.Cli qualified as CodexCli+import Baikai.ThinkingLevel (ThinkingLevel (..), renderThinkingLevel) import Control.Exception (try) import Data.Text (Text) import Data.Text qualified as Text@@ -33,7 +38,9 @@  data AgentModelConfig = AgentModelConfig   { agentProvider :: AgentProvider,-    agentModel :: Maybe Text+    agentModel :: Maybe Text,+    -- | Reasoning effort. 'Nothing' leaves the provider/CLI default alone.+    agentEffort :: Maybe ThinkingLevel   }   deriving stock (Eq, Show) @@ -56,9 +63,44 @@ defaultAgentModelConfig =   AgentModelConfig     { agentProvider = AgentProviderClaudeCli,-      agentModel = Nothing+      agentModel = Nothing,+      agentEffort = Nothing     } +-- | Parse a reasoning-effort level name (case-insensitive) into a Baikai+-- 'ThinkingLevel'. Accepts the six canonical Baikai level names.+effortFromText :: Text -> Either Text ThinkingLevel+effortFromText raw =+  case Text.toLower (Text.strip raw) of+    "minimal" -> Right ThinkingMinimal+    "low" -> Right ThinkingLow+    "medium" -> Right ThinkingMedium+    "high" -> Right ThinkingHigh+    "xhigh" -> Right ThinkingXHigh+    "max" -> Right ThinkingMax+    other ->+      Left $+        "Unknown reasoning effort '"+          <> other+          <> "'. Expected one of: minimal, low, medium, high, xhigh, max."++-- | Render a 'ThinkingLevel' to its canonical name (via Baikai).+effortToText :: ThinkingLevel -> Text+effortToText = renderThinkingLevel++-- | The deterministic default model for a provider when the user has configured+-- none. The two local CLI providers pin a specific model so a @seihou agent@+-- session never inherits whatever model the ambient @claude@ or @codex@ session+-- happens to have active — that would be non-deterministic and could silently+-- run a token-hungry model another session left selected. The API providers+-- return 'Nothing' here; they already send an explicit model chosen in+-- 'buildBaikaiModel', so they are deterministic without a pinned default.+defaultModelForProvider :: AgentProvider -> Maybe Text+defaultModelForProvider AgentProviderClaudeCli = Just "claude-opus-4-8"+defaultModelForProvider AgentProviderCodexCli = Just "gpt-5.6-terra"+defaultModelForProvider AgentProviderAnthropic = Nothing+defaultModelForProvider AgentProviderOpenAI = Nothing+ providerFromText :: Text -> Either Text AgentProvider providerFromText raw =   case Text.toLower (Text.strip raw) of@@ -132,7 +174,8 @@           { Baikai.systemPrompt = Just req.completionSystemPrompt,             Baikai.messages = initialMessages           }-  result <- try (Baikai.completeRequest model ctx Baikai.emptyOptions) :: IO (Either Baikai.BaikaiError Baikai.Response)+      options = Baikai.emptyOptions {BaikaiOptions.thinking = req.completionModelConfig.agentEffort}+  result <- try (Baikai.completeRequest model ctx options) :: IO (Either Baikai.BaikaiError Baikai.Response)   pure $ case result of     Left err -> Left (Text.pack (show err))     Right resp ->
src/Seihou/CLI/AgentConfig.hs view
@@ -1,19 +1,52 @@ module Seihou.CLI.AgentConfig-  ( AgentConfigInputs (..),+  ( -- * Inputs+    AgentConfigInputs (..),+    baseAgentConfigInputs,++    -- * Command identity+    AgentCommandName (..),+    agentCommandSegment,+    agentCommandLabel,+    allAgentCommands,++    -- * Config keys and environment variables     agentProviderConfigKey,     agentModelConfigKey,+    agentEffortConfigKey,+    agentCommandProviderConfigKey,+    agentCommandModelConfigKey,+    agentCommandEffortConfigKey,     agentProviderEnvVar,     agentModelEnvVar,+    agentEffortEnvVar,++    -- * Provenance+    AgentConfigSource (..),+    AgentField (..),+    ResolvedAgentField (..),+    agentConfigSourceLabel,++    -- * Resolution     resolveAgentModelConfig,+    resolveAgentModelConfigFor,     loadAgentModelConfig,+    loadAgentModelConfigFor,++    -- * Whole-configuration inspection+    ResolvedCommandConfig (..),+    loadResolvedAgentConfig,   ) where +import Baikai.ThinkingLevel (ThinkingLevel) import Data.Map.Strict qualified as Map import Data.Text qualified as T import Seihou.CLI.AgentCompletion   ( AgentModelConfig (..),+    AgentProvider (..),     defaultAgentModelConfig,+    defaultModelForProvider,+    effortFromText,     providerFromText,   ) import Seihou.CLI.Shared (formatConfigError)@@ -22,45 +55,393 @@ import Seihou.Prelude import System.Environment (lookupEnv) +-- | All the raw material provider/model resolution draws on, in one record so+-- the pure resolver can be unit-tested without touching the filesystem or the+-- environment.+--+-- The two @cli*FromSubcommand@ flags record whether the (already combined)+-- winning CLI flag originated from the subcommand's own @--provider@/@--model@+-- (as opposed to the parent @seihou agent@ flag). They only affect the+-- provenance label reported for a CLI-sourced value; they never change which+-- value wins. data AgentConfigInputs = AgentConfigInputs   { cliProvider :: Maybe Text,     cliModel :: Maybe Text,+    cliEffort :: Maybe Text,+    cliProviderFromSubcommand :: Bool,+    cliModelFromSubcommand :: Bool,+    cliEffortFromSubcommand :: Bool,     envProvider :: Maybe Text,     envModel :: Maybe Text,+    envEffort :: Maybe Text,     localConfig :: Map Text Text,     globalConfig :: Map Text Text   }   deriving stock (Eq, Show) +-- | An 'AgentConfigInputs' with nothing set: no flags, no environment, empty+-- config maps. Handy as a base for tests and for callers that only populate a+-- few fields.+baseAgentConfigInputs :: AgentConfigInputs+baseAgentConfigInputs =+  AgentConfigInputs+    { cliProvider = Nothing,+      cliModel = Nothing,+      cliEffort = Nothing,+      cliProviderFromSubcommand = False,+      cliModelFromSubcommand = False,+      cliEffortFromSubcommand = False,+      envProvider = Nothing,+      envModel = Nothing,+      envEffort = Nothing,+      localConfig = Map.empty,+      globalConfig = Map.empty+    }++-- | The agent-driven commands whose provider/model can be configured+-- independently. Each maps to a config-key segment (see 'agentCommandSegment').+data AgentCommandName+  = AgentCmdAssist+  | AgentCmdBootstrap+  | AgentCmdSetup+  | AgentCmdRun+  | AgentCmdMigrate+  | AgentCmdPromptRun+  deriving stock (Eq, Show, Enum, Bounded)++-- | The token used inside per-command config keys, e.g. @"assist"@ in+-- @agent.assist.model@.+agentCommandSegment :: AgentCommandName -> Text+agentCommandSegment AgentCmdAssist = "assist"+agentCommandSegment AgentCmdBootstrap = "bootstrap"+agentCommandSegment AgentCmdSetup = "setup"+agentCommandSegment AgentCmdRun = "run"+agentCommandSegment AgentCmdMigrate = "migrate"+agentCommandSegment AgentCmdPromptRun = "prompt-run"++-- | Human-facing label for display, e.g. @"prompt run"@ for the two-word+-- @seihou prompt run@ command.+agentCommandLabel :: AgentCommandName -> Text+agentCommandLabel AgentCmdPromptRun = "prompt run"+agentCommandLabel c = agentCommandSegment c++-- | Every configurable agent command, in display order.+allAgentCommands :: [AgentCommandName]+allAgentCommands = [minBound .. maxBound]++-- | The cross-command default provider key, @agent.provider@. agentProviderConfigKey :: Text agentProviderConfigKey = "agent.provider" +-- | The cross-command default model key, @agent.model@. agentModelConfigKey :: Text agentModelConfigKey = "agent.model" +-- | The cross-command default reasoning-effort key, @agent.effort@.+agentEffortConfigKey :: Text+agentEffortConfigKey = "agent.effort"++-- | The per-command provider key, e.g. @agent.assist.provider@.+agentCommandProviderConfigKey :: AgentCommandName -> Text+agentCommandProviderConfigKey c = "agent." <> agentCommandSegment c <> ".provider"++-- | The per-command model key, e.g. @agent.run.model@.+agentCommandModelConfigKey :: AgentCommandName -> Text+agentCommandModelConfigKey c = "agent." <> agentCommandSegment c <> ".model"++-- | The per-command reasoning-effort key, e.g. @agent.run.effort@.+agentCommandEffortConfigKey :: AgentCommandName -> Text+agentCommandEffortConfigKey c = "agent." <> agentCommandSegment c <> ".effort"+ agentProviderEnvVar :: String agentProviderEnvVar = "SEIHOU_AGENT_PROVIDER"  agentModelEnvVar :: String agentModelEnvVar = "SEIHOU_AGENT_MODEL" +agentEffortEnvVar :: String+agentEffortEnvVar = "SEIHOU_AGENT_EFFORT"++-- | Which of the resolvable fields a value belongs to. Used only to build+-- provenance labels.+data AgentField = ProviderField | ModelField | EffortField+  deriving stock (Eq, Show)++-- | Where a resolved value came from, highest precedence first.+data AgentConfigSource+  = -- | @--provider@/@--model@ on the subcommand.+    SourceCliSubcommand+  | -- | @--provider@/@--model@ on @seihou agent@.+    SourceCliParent+  | -- | @SEIHOU_AGENT_PROVIDER@/@SEIHOU_AGENT_MODEL@.+    SourceEnv+  | -- | Local @agent.<command>.<field>@.+    SourceLocalCommand+  | -- | Local @agent.<field>@.+    SourceLocalDefault+  | -- | Global @agent.<command>.<field>@.+    SourceGlobalCommand+  | -- | Global @agent.<field>@.+    SourceGlobalDefault+  | -- | The hard-coded fallback (provider @claude-cli@, model unset).+    SourceBuiltinDefault+  deriving stock (Eq, Show)++-- | A resolved value paired with the source that supplied it.+data ResolvedAgentField a = ResolvedAgentField+  { resolvedValue :: a,+    resolvedSource :: AgentConfigSource+  }+  deriving stock (Eq, Show)++-- | A short human label describing where a value came from, suitable for+-- bracketed display. For config-file sources it names the concrete key that+-- won, e.g. @"local: agent.run.model"@ or @"global: agent.provider"@.+agentConfigSourceLabel :: AgentCommandName -> AgentField -> AgentConfigSource -> Text+agentConfigSourceLabel c field src =+  case src of+    SourceCliSubcommand -> "flag on subcommand"+    SourceCliParent -> "flag on `seihou agent`"+    SourceEnv -> "env: " <> T.pack (envVarName field)+    SourceLocalCommand -> "local: " <> commandKey field c+    SourceLocalDefault -> "local: " <> defaultKey field+    SourceGlobalCommand -> "global: " <> commandKey field c+    SourceGlobalDefault -> "global: " <> defaultKey field+    SourceBuiltinDefault -> "built-in default"++envVarName :: AgentField -> String+envVarName ProviderField = agentProviderEnvVar+envVarName ModelField = agentModelEnvVar+envVarName EffortField = agentEffortEnvVar++defaultKey :: AgentField -> Text+defaultKey ProviderField = agentProviderConfigKey+defaultKey ModelField = agentModelConfigKey+defaultKey EffortField = agentEffortConfigKey++commandKey :: AgentField -> AgentCommandName -> Text+commandKey ProviderField = agentCommandProviderConfigKey+commandKey ModelField = agentCommandModelConfigKey+commandKey EffortField = agentCommandEffortConfigKey++-- | The full result of resolving one command's provider and model, with+-- provenance, used by the @seihou agent config@ inspection command.+data ResolvedCommandConfig = ResolvedCommandConfig+  { rccCommand :: AgentCommandName,+    rccProvider :: ResolvedAgentField AgentProvider,+    rccModel :: ResolvedAgentField (Maybe Text),+    rccEffort :: ResolvedAgentField (Maybe ThinkingLevel)+  }+  deriving stock (Eq, Show)++-- | Flat resolver, preserved for backward compatibility. It never consults the+-- per-command config keys, so a caller with only @agent.provider@/@agent.model@+-- set (or none) gets exactly the historical behavior. resolveAgentModelConfig :: AgentConfigInputs -> Either Text AgentModelConfig resolveAgentModelConfig inputs = do   provider <--    maybe-      (Right defaultAgentModelConfig.agentProvider)-      providerFromText-      (firstNonBlank [inputs.cliProvider, inputs.envProvider, configValue inputs.localConfig agentProviderConfigKey, configValue inputs.globalConfig agentProviderConfigKey])+    resolveProvider+      [ candidate inputs.cliProvider SourceCliSubcommand,+        candidate inputs.envProvider SourceEnv,+        candidate (Map.lookup agentProviderConfigKey inputs.localConfig) SourceLocalDefault,+        candidate (Map.lookup agentProviderConfigKey inputs.globalConfig) SourceGlobalDefault+      ]+  let modelField =+        applyProviderDefaultModel provider.resolvedValue $+          resolveModel+            [ candidate inputs.cliModel SourceCliSubcommand,+              candidate inputs.envModel SourceEnv,+              candidate (Map.lookup agentModelConfigKey inputs.localConfig) SourceLocalDefault,+              candidate (Map.lookup agentModelConfigKey inputs.globalConfig) SourceGlobalDefault+            ]   pure     AgentModelConfig-      { agentProvider = provider,-        agentModel = firstNonBlank [inputs.cliModel, inputs.envModel, configValue inputs.localConfig agentModelConfigKey, configValue inputs.globalConfig agentModelConfigKey]+      { agentProvider = provider.resolvedValue,+        agentModel = modelField.resolvedValue,+        agentEffort = Nothing       } +-- | Resolve the provider, model, and reasoning effort for a specific command,+-- honoring the full precedence chain including the per-command config tiers, and+-- reporting the source of each value.+--+-- Precedence, highest first: subcommand flag, parent @agent@ flag, environment+-- variable, local @agent.<command>.<field>@, local @agent.<field>@, global+-- @agent.<command>.<field>@, global @agent.<field>@, built-in default.+resolveAgentModelConfigFor ::+  AgentCommandName ->+  AgentConfigInputs ->+  Either+    Text+    ( ResolvedAgentField AgentProvider,+      ResolvedAgentField (Maybe Text),+      ResolvedAgentField (Maybe ThinkingLevel)+    )+resolveAgentModelConfigFor c inputs = do+  provider <-+    (\p -> ResolvedAgentField p.resolvedValue p.resolvedSource)+      <$> resolveProvider (providerCandidates c inputs)+  let model = applyProviderDefaultModel provider.resolvedValue (resolveModel (modelCandidates c inputs))+  effort <- resolveEffort (effortCandidates c inputs)+  pure (provider, model, effort)++-- | When no model was configured (source is the built-in default), substitute+-- the provider's deterministic default so the two local CLI providers always+-- resolve to a concrete model instead of 'Nothing'. The source stays+-- 'SourceBuiltinDefault' — the value is a built-in, just a non-empty one.+applyProviderDefaultModel :: AgentProvider -> ResolvedAgentField (Maybe Text) -> ResolvedAgentField (Maybe Text)+applyProviderDefaultModel prov field =+  case field.resolvedValue of+    Just _ -> field+    Nothing -> case defaultModelForProvider prov of+      Just m -> field {resolvedValue = Just m}+      Nothing -> field++providerCandidates :: AgentCommandName -> AgentConfigInputs -> [(Maybe Text, AgentConfigSource)]+providerCandidates c inputs =+  [ candidate inputs.cliProvider (cliSource inputs.cliProviderFromSubcommand),+    candidate inputs.envProvider SourceEnv,+    candidate (Map.lookup (agentCommandProviderConfigKey c) inputs.localConfig) SourceLocalCommand,+    candidate (Map.lookup agentProviderConfigKey inputs.localConfig) SourceLocalDefault,+    candidate (Map.lookup (agentCommandProviderConfigKey c) inputs.globalConfig) SourceGlobalCommand,+    candidate (Map.lookup agentProviderConfigKey inputs.globalConfig) SourceGlobalDefault+  ]++modelCandidates :: AgentCommandName -> AgentConfigInputs -> [(Maybe Text, AgentConfigSource)]+modelCandidates c inputs =+  [ candidate inputs.cliModel (cliSource inputs.cliModelFromSubcommand),+    candidate inputs.envModel SourceEnv,+    candidate (Map.lookup (agentCommandModelConfigKey c) inputs.localConfig) SourceLocalCommand,+    candidate (Map.lookup agentModelConfigKey inputs.localConfig) SourceLocalDefault,+    candidate (Map.lookup (agentCommandModelConfigKey c) inputs.globalConfig) SourceGlobalCommand,+    candidate (Map.lookup agentModelConfigKey inputs.globalConfig) SourceGlobalDefault+  ]++effortCandidates :: AgentCommandName -> AgentConfigInputs -> [(Maybe Text, AgentConfigSource)]+effortCandidates c inputs =+  [ candidate inputs.cliEffort (cliSource inputs.cliEffortFromSubcommand),+    candidate inputs.envEffort SourceEnv,+    candidate (Map.lookup (agentCommandEffortConfigKey c) inputs.localConfig) SourceLocalCommand,+    candidate (Map.lookup agentEffortConfigKey inputs.localConfig) SourceLocalDefault,+    candidate (Map.lookup (agentCommandEffortConfigKey c) inputs.globalConfig) SourceGlobalCommand,+    candidate (Map.lookup agentEffortConfigKey inputs.globalConfig) SourceGlobalDefault+  ]++cliSource :: Bool -> AgentConfigSource+cliSource True = SourceCliSubcommand+cliSource False = SourceCliParent++-- | Resolve a provider from an ordered candidate list, parsing the winning text+-- and falling back to the built-in default provider when nothing is set.+resolveProvider :: [(Maybe Text, AgentConfigSource)] -> Either Text (ResolvedAgentField AgentProvider)+resolveProvider candidates =+  case firstNonBlankWithSource candidates of+    Just (txt, src) -> (\p -> ResolvedAgentField p src) <$> providerFromText txt+    Nothing -> Right (ResolvedAgentField defaultAgentModelConfig.agentProvider SourceBuiltinDefault)++-- | Resolve a model from an ordered candidate list. An unset model resolves to+-- 'Nothing' with source 'SourceBuiltinDefault', letting the provider pick.+resolveModel :: [(Maybe Text, AgentConfigSource)] -> ResolvedAgentField (Maybe Text)+resolveModel candidates =+  case firstNonBlankWithSource candidates of+    Just (txt, src) -> ResolvedAgentField (Just txt) src+    Nothing -> ResolvedAgentField Nothing SourceBuiltinDefault++-- | Resolve a reasoning effort from an ordered candidate list. The winning text+-- is parsed with 'effortFromText'; a parse failure returns 'Left'. An unset+-- effort resolves to 'Nothing' with source 'SourceBuiltinDefault', which leaves+-- the provider/CLI default untouched.+resolveEffort :: [(Maybe Text, AgentConfigSource)] -> Either Text (ResolvedAgentField (Maybe ThinkingLevel))+resolveEffort candidates =+  case firstNonBlankWithSource candidates of+    Just (txt, src) -> (\lvl -> ResolvedAgentField (Just lvl) src) <$> effortFromText txt+    Nothing -> Right (ResolvedAgentField Nothing SourceBuiltinDefault)++candidate :: Maybe Text -> AgentConfigSource -> (Maybe Text, AgentConfigSource)+candidate value src = (value, src)++-- | The leftmost candidate whose value is present and non-blank (whitespace is+-- stripped, and @""@ counts as absent), together with its source.+firstNonBlankWithSource :: [(Maybe Text, AgentConfigSource)] -> Maybe (Text, AgentConfigSource)+firstNonBlankWithSource =+  foldr step Nothing+  where+    step (value, src) acc =+      case T.strip <$> value of+        Just "" -> acc+        Just stripped -> Just (stripped, src)+        Nothing -> acc++-- | Read the two environment variables and the local + global config, then run+-- the flat resolver. Preserved for backward compatibility. loadAgentModelConfig :: Maybe Text -> Maybe Text -> IO (Either Text AgentModelConfig) loadAgentModelConfig cliProvider cliModel = do+  inputsOrErr <- gatherAgentConfigInputs cliProvider cliModel Nothing False False False+  pure (inputsOrErr >>= resolveAgentModelConfig)++-- | Read the environment and config, then resolve provider/model/effort for a+-- specific command, projecting away the provenance the command handler does not+-- need.+loadAgentModelConfigFor ::+  AgentCommandName ->+  -- | winning provider flag (subcommand @<|>@ parent)+  Maybe Text ->+  -- | winning model flag+  Maybe Text ->+  -- | winning effort flag+  Maybe Text ->+  -- | provider flag came from the subcommand?+  Bool ->+  -- | model flag came from the subcommand?+  Bool ->+  -- | effort flag came from the subcommand?+  Bool ->+  IO (Either Text AgentModelConfig)+loadAgentModelConfigFor c cliProvider cliModel cliEffort providerFromSub modelFromSub effortFromSub = do+  inputsOrErr <- gatherAgentConfigInputs cliProvider cliModel cliEffort providerFromSub modelFromSub effortFromSub+  pure $ do+    inputs <- inputsOrErr+    (provider, model, effort) <- resolveAgentModelConfigFor c inputs+    pure+      AgentModelConfig+        { agentProvider = provider.resolvedValue,+          agentModel = model.resolvedValue,+          agentEffort = effort.resolvedValue+        }++-- | Resolve every configurable command from the real environment and config,+-- with no CLI flags, for the @seihou agent config@ inspection view.+loadResolvedAgentConfig :: IO (Either Text [ResolvedCommandConfig])+loadResolvedAgentConfig = do+  inputsOrErr <- gatherAgentConfigInputs Nothing Nothing Nothing False False False+  pure $ do+    inputs <- inputsOrErr+    traverse (resolveOne inputs) allAgentCommands+  where+    resolveOne inputs c = do+      (provider, model, effort) <- resolveAgentModelConfigFor c inputs+      pure+        ResolvedCommandConfig+          { rccCommand = c,+            rccProvider = provider,+            rccModel = model,+            rccEffort = effort+          }++-- | Shared IO: read @SEIHOU_AGENT_*@ and the local + global config maps into an+-- 'AgentConfigInputs'. Any config read error surfaces as 'Left'.+gatherAgentConfigInputs ::+  Maybe Text ->+  Maybe Text ->+  Maybe Text ->+  Bool ->+  Bool ->+  Bool ->+  IO (Either Text AgentConfigInputs)+gatherAgentConfigInputs cliProvider cliModel cliEffort providerFromSub modelFromSub effortFromSub = do   envProvider <- fmap T.pack <$> lookupEnv agentProviderEnvVar   envModel <- fmap T.pack <$> lookupEnv agentModelEnvVar+  envEffort <- fmap T.pack <$> lookupEnv agentEffortEnvVar   (localResult, globalResult) <- runEff $ runConfigReader $ do     local <- readLocalConfig     global <- readGlobalConfig@@ -68,26 +449,17 @@   pure $ do     local <- first formatConfigError localResult     global <- first formatConfigError globalResult-    resolveAgentModelConfig+    pure       AgentConfigInputs         { cliProvider = cliProvider,           cliModel = cliModel,+          cliEffort = cliEffort,+          cliProviderFromSubcommand = providerFromSub,+          cliModelFromSubcommand = modelFromSub,+          cliEffortFromSubcommand = effortFromSub,           envProvider = envProvider,           envModel = envModel,+          envEffort = envEffort,           localConfig = local,           globalConfig = global         }--configValue :: Map Text Text -> Text -> Maybe Text-configValue config key = Map.lookup key config--firstNonBlank :: [Maybe Text] -> Maybe Text-firstNonBlank =-  foldr-    ( \candidate acc ->-        case T.strip <$> candidate of-          Just "" -> acc-          Just value -> Just value-          Nothing -> acc-    )-    Nothing
+ src/Seihou/CLI/AgentConfigShow.hs view
@@ -0,0 +1,108 @@+module Seihou.CLI.AgentConfigShow+  ( handleAgentConfigShow,+    formatResolvedAgentConfig,+  )+where++import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Seihou.CLI.AgentCompletion (effortToText, providerToText)+import Seihou.CLI.AgentConfig+  ( AgentField (..),+    ResolvedAgentField (..),+    ResolvedCommandConfig (..),+    agentCommandLabel,+    agentConfigSourceLabel,+    loadResolvedAgentConfig,+  )+import Seihou.Prelude+import System.Exit (exitFailure)++-- | @seihou agent config@: read the real environment and config, resolve the+-- provider, model, and reasoning effort for every agent command, and print a+-- table labelling the source that supplied each value, followed by the+-- precedence legend.+handleAgentConfigShow :: IO ()+handleAgentConfigShow = do+  result <- loadResolvedAgentConfig+  case result of+    Left err -> do+      TIO.putStrLn $ "Error: " <> err+      exitFailure+    Right resolved -> TIO.putStr (formatResolvedAgentConfig resolved)++-- | Render the resolved per-command configuration as the displayed block. Pure,+-- so it is unit-testable without touching the filesystem.+formatResolvedAgentConfig :: [ResolvedCommandConfig] -> Text+formatResolvedAgentConfig resolved =+  T.unlines $+    [ "Resolved agent provider, model, and effort per command",+      "(highest-precedence source wins; see precedence list below)",+      ""+    ]+      <> concatMap renderCommand resolved+      <> ["", precedenceLegend]+  where+    labelWidth = maximum (0 : map (\rcc -> T.length (agentCommandLabel rcc.rccCommand)) resolved)+    valueWidth = maximum (0 : concatMap commandValueWidths resolved)++    commandValueWidths rcc =+      [ T.length (providerValue rcc),+        T.length (modelValue rcc),+        T.length (effortValue rcc)+      ]++    providerValue rcc = providerToText rcc.rccProvider.resolvedValue+    modelValue rcc = maybe "(default)" id rcc.rccModel.resolvedValue+    effortValue rcc = maybe "(default)" effortToText rcc.rccEffort.resolvedValue++    renderCommand rcc =+      let cmd = rcc.rccCommand+          label = agentCommandLabel cmd+       in [ row+              (padRight labelWidth label)+              "provider"+              (providerValue rcc)+              (agentConfigSourceLabel cmd ProviderField rcc.rccProvider.resolvedSource),+            row+              (padRight labelWidth "")+              "model   "+              (modelValue rcc)+              (agentConfigSourceLabel cmd ModelField rcc.rccModel.resolvedSource),+            row+              (padRight labelWidth "")+              "effort  "+              (effortValue rcc)+              (agentConfigSourceLabel cmd EffortField rcc.rccEffort.resolvedSource)+          ]++    row label field value sourceLabel =+      "  "+        <> label+        <> "  "+        <> field+        <> "  "+        <> padRight valueWidth value+        <> "  ["+        <> sourceLabel+        <> "]"++padRight :: Int -> Text -> Text+padRight width value = value <> T.replicate (max 0 (width - T.length value)) " "++precedenceLegend :: Text+precedenceLegend =+  T.intercalate+    "\n"+    [ "Precedence, highest first:",+      "  1. --provider / --model / --effort flag on the subcommand",+      "  2. --provider / --model / --effort flag on `seihou agent`",+      "  3. SEIHOU_AGENT_PROVIDER / SEIHOU_AGENT_MODEL / SEIHOU_AGENT_EFFORT environment variables",+      "  4. local  .seihou/config.dhall          agent.<command>.{provider,model,effort}",+      "  5. local  .seihou/config.dhall          agent.{provider,model,effort}",+      "  6. global ~/.config/seihou/config.dhall  agent.<command>.{provider,model,effort}",+      "  7. global ~/.config/seihou/config.dhall  agent.{provider,model,effort}",+      "  8. built-in default: provider claude-cli; model pinned per provider",+      "     (claude-cli -> claude-opus-4-8, codex-cli -> gpt-5.6-terra); effort unset",+      "     (the CLI/provider chooses its own reasoning effort)"+    ]
+ src/Seihou/CLI/AgentModels.hs view
@@ -0,0 +1,103 @@+module Seihou.CLI.AgentModels+  ( availableAgentModels,+    providersForModel,+    filterAgentModels,+    formatAgentModels,+  )+where++import Baikai.Model (Model)+import Baikai.Model qualified as BaikaiModel+import Baikai.Models.Generated qualified as Models+import Data.List (sortOn)+import Data.Text (Text)+import Data.Text qualified as Text+import Seihou.CLI.AgentCompletion+  ( AgentProvider (..),+    providerToText,+  )++availableAgentModels :: [Model]+availableAgentModels =+  [ Models.anthropic_claude_fable_5,+    Models.anthropic_claude_haiku_4_5,+    Models.anthropic_claude_opus_4_5,+    Models.anthropic_claude_opus_4_6,+    Models.anthropic_claude_opus_4_7,+    Models.anthropic_claude_opus_4_8,+    Models.anthropic_claude_sonnet_4_5,+    Models.anthropic_claude_sonnet_4_6,+    Models.anthropic_claude_sonnet_5,+    Models.openai_gpt_4_1,+    Models.openai_gpt_4_1_mini,+    Models.openai_gpt_4_1_nano,+    Models.openai_gpt_4o,+    Models.openai_gpt_4o_mini,+    Models.openai_gpt_5,+    Models.openai_gpt_5_1,+    Models.openai_gpt_5_2,+    Models.openai_gpt_5_4,+    Models.openai_gpt_5_4_mini,+    Models.openai_gpt_5_4_nano,+    Models.openai_gpt_5_5,+    Models.openai_gpt_5_6,+    Models.openai_gpt_5_6_luna,+    Models.openai_gpt_5_6_sol,+    Models.openai_gpt_5_6_terra,+    Models.openai_gpt_5_mini,+    Models.openai_gpt_5_nano,+    Models.openai_o1,+    Models.openai_o3,+    Models.openai_o3_mini,+    Models.openai_o4_mini+  ]++providersForModel :: Model -> [AgentProvider]+providersForModel model =+  case BaikaiModel.provider model of+    "anthropic" -> [AgentProviderAnthropic, AgentProviderClaudeCli]+    "openai" -> [AgentProviderOpenAI, AgentProviderCodexCli]+    _ -> []++filterAgentModels :: Maybe AgentProvider -> [Model] -> [Model]+filterAgentModels Nothing = id+filterAgentModels (Just provider) =+  filter (elem provider . providersForModel)++formatAgentModels :: Maybe AgentProvider -> [Model] -> Text+formatAgentModels providerFilter models =+  Text.unlines $+    [ "Available agent models:",+      "",+      formatRow modelWidth nameWidth "MODEL" "NAME" "PROVIDERS"+    ]+      <> map formatModel sortedModels+      <> [ "",+           Text.pack (show (length sortedModels)) <> " models found.",+           "Provider-specific aliases and custom model IDs remain accepted by --model."+         ]+  where+    sortedModels =+      sortOn+        (\model -> (BaikaiModel.provider model, BaikaiModel.modelId model))+        (filterAgentModels providerFilter models)+    modelWidth = maximum (Text.length "MODEL" : map (Text.length . BaikaiModel.modelId) sortedModels)+    nameWidth = maximum (Text.length "NAME" : map (Text.length . BaikaiModel.name) sortedModels)+    formatModel model =+      formatRow+        modelWidth+        nameWidth+        (BaikaiModel.modelId model)+        (BaikaiModel.name model)+        (Text.intercalate ", " (map providerToText (providersForModel model)))++formatRow :: Int -> Int -> Text -> Text -> Text -> Text+formatRow modelWidth nameWidth model name providers =+  padRight modelWidth model+    <> "  "+    <> padRight nameWidth name+    <> "  "+    <> providers++padRight :: Int -> Text -> Text+padRight width value = value <> Text.replicate (width - Text.length value) " "
+ src/Seihou/CLI/AppliedBlueprintMigration.hs view
@@ -0,0 +1,31 @@+-- | Durable manifest receipts for agent-guided blueprint migrations.+module Seihou.CLI.AppliedBlueprintMigration+  ( recordAppliedBlueprintMigration,+  )+where++import Seihou.Core.Types (AppliedBlueprintMigration (..))+import Seihou.Effect.Filesystem (createDirectoryIfMissing)+import Seihou.Effect.FilesystemInterp (runFilesystem)+import Seihou.Effect.ManifestStore (readManifest, writeManifest)+import Seihou.Effect.ManifestStoreInterp (runManifestStore)+import Seihou.Manifest.Types (emptyManifest, writeAppliedBlueprintMigration)+import Seihou.Prelude+import System.FilePath (takeDirectory)++-- | Read or create the project manifest, upsert one exact migration receipt,+-- and write it atomically. A corrupt existing manifest is reported and left+-- untouched rather than being replaced.+recordAppliedBlueprintMigration :: FilePath -> AppliedBlueprintMigration -> IO (Either Text ())+recordAppliedBlueprintMigration manifestPath receipt =+  runEff $ runFilesystem $ runManifestStore manifestPath $ do+    createDirectoryIfMissing True (takeDirectory manifestPath)+    mManifest <- readManifest+    case mManifest of+      Right (Just manifest) -> do+        writeManifest (writeAppliedBlueprintMigration receipt manifest)+        pure (Right ())+      Right Nothing -> do+        writeManifest (writeAppliedBlueprintMigration receipt (emptyManifest receipt.appliedAt))+        pure (Right ())+      Left err -> pure (Left err)
+ src/Seihou/CLI/BlueprintExecution.hs view
@@ -0,0 +1,165 @@+-- | Shared preparation for normal and migration blueprint execution.+--+-- This module deliberately knows nothing about executable command options or+-- provider processes. It resolves the blueprint's variables through Seihou's+-- standard precedence chain, prepares reference-file access, and renders the+-- shared blueprint prompt once for downstream execution modes.+module Seihou.CLI.BlueprintExecution+  ( BlueprintExecutionRequest (..),+    PreparedBlueprintExecution (..),+    prepareBlueprintExecution,+    renderBlueprintText,+    varValueToText,+  )+where++import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe)+import Data.Text qualified as T+import Seihou.CLI.AgentLaunch+  ( formatReferenceFiles,+    formatReferenceFilesDir,+    resolveBlueprintTools,+  )+import Seihou.CLI.Shared+  ( deriveNamespace,+    toVarNameMap,+    unwrapConfig,+  )+import Seihou.Composition.Instance (primaryInstance)+import Seihou.Composition.Resolve (resolveWithPrompts)+import Seihou.Core.Context (resolveContext)+import Seihou.Core.Types+import Seihou.Effect.ConfigReader+  ( readContextConfig,+    readGlobalConfig,+    readLocalConfig,+    readNamespaceConfig,+  )+import Seihou.Effect.ConfigReaderInterp (runConfigReader)+import Seihou.Effect.ConsoleInterp (runConsole)+import Seihou.Prelude+import System.Directory (doesDirectoryExist, makeAbsolute)+import System.Environment (getEnvironment)++-- | Inputs shared by normal blueprint runs and blueprint migrations.+-- Provider capability is represented as a boolean so this library module does+-- not need to know about Baikai or the executable command model.+data BlueprintExecutionRequest = BlueprintExecutionRequest+  { executionBlueprint :: Blueprint,+    executionBlueprintDir :: FilePath,+    executionVariableOverrides :: [(Text, Text)],+    executionNamespaceOverride :: Maybe Text,+    executionContextOverride :: Maybe Text,+    executionCanMountFiles :: Bool,+    executionLogLevel :: LogLevel+  }+  deriving stock (Eq, Show)++-- | Prepared state that both execution modes consume. The mounted path is+-- absolute when present; the access text preserves the existing API-provider+-- explanation when local files cannot be mounted.+data PreparedBlueprintExecution = PreparedBlueprintExecution+  { preparedBlueprint :: Blueprint,+    preparedBlueprintDir :: FilePath,+    preparedResolvedVariables :: Map VarName ResolvedVar,+    preparedMountedFilesDir :: Maybe FilePath,+    preparedReferenceFiles :: Text,+    preparedReferenceFilesAccess :: Text,+    preparedSharedPrompt :: Text,+    preparedAllowedTools :: [String]+  }+  deriving stock (Eq, Show)++-- | Resolve one blueprint through the same CLI/environment/config/prompt+-- precedence used by @seihou run@ and the existing agent runner.+prepareBlueprintExecution ::+  BlueprintExecutionRequest ->+  IO (Either [VarError] PreparedBlueprintExecution)+prepareBlueprintExecution request = do+  let bp = request.executionBlueprint+      blueprintDir = request.executionBlueprintDir+      filesDir = blueprintDir </> "files"+  filesExist <- doesDirectoryExist filesDir+  mountedFilesDir <-+    if filesExist && request.executionCanMountFiles+      then Just <$> makeAbsolute filesDir+      else pure Nothing++  let placeholderModule =+        Module+          { name = bp.name,+            version = bp.version,+            description = bp.description,+            vars = bp.vars,+            exports = [],+            prompts = bp.prompts,+            steps = [],+            commands = [],+            dependencies = [],+            removal = Nothing,+            migrations = []+          }+      placeholderInst = primaryInstance bp.name+      placeholderTriple = (placeholderInst, placeholderModule, blueprintDir)++  envPairs <- getEnvironment+  let cliOverrides =+        Map.fromList+          [(VarName key, value) | (key, value) <- request.executionVariableOverrides]+      envVars = Map.fromList [(T.pack key, T.pack value) | (key, value) <- envPairs]+      namespace =+        fromMaybe (deriveNamespace bp.name) request.executionNamespaceOverride+  context <- resolveContext request.executionContextOverride envVars+  let contextName = fromMaybe "" context++  resolveResult <- runEff $ runConfigReader $ runConsole $ do+    localCfg <- readLocalConfig >>= unwrapConfig request.executionLogLevel+    nsCfg <- readNamespaceConfig namespace >>= unwrapConfig request.executionLogLevel+    ctxCfg <- readContextConfig contextName >>= unwrapConfig request.executionLogLevel+    globalCfg <- readGlobalConfig >>= unwrapConfig request.executionLogLevel+    resolveWithPrompts+      [placeholderTriple]+      cliOverrides+      envVars+      namespace+      contextName+      (toVarNameMap localCfg)+      (toVarNameMap nsCfg)+      (toVarNameMap ctxCfg)+      (toVarNameMap globalCfg)++  pure $ do+    allResolved <- resolveResult+    let resolved = Map.findWithDefault Map.empty placeholderInst allResolved+    Right+      PreparedBlueprintExecution+        { preparedBlueprint = bp,+          preparedBlueprintDir = blueprintDir,+          preparedResolvedVariables = resolved,+          preparedMountedFilesDir = mountedFilesDir,+          preparedReferenceFiles = formatReferenceFiles bp.files,+          preparedReferenceFilesAccess = formatReferenceFilesDir mountedFilesDir,+          preparedSharedPrompt = renderBlueprintText resolved bp.prompt,+          preparedAllowedTools = resolveBlueprintTools bp.allowedTools+        }++-- | Substitute resolved blueprint variables into any blueprint-owned text.+renderBlueprintText :: Map VarName ResolvedVar -> Text -> Text+renderBlueprintText resolved template =+  foldl'+    ( \rendered (name, value) ->+        T.replace+          ("{{" <> name.unVarName <> "}}")+          (varValueToText value.value)+          rendered+    )+    template+    (Map.toList resolved)++varValueToText :: VarValue -> Text+varValueToText (VText value) = value+varValueToText (VBool True) = "true"+varValueToText (VBool False) = "false"+varValueToText (VInt value) = T.pack (show value)+varValueToText (VList values) = T.intercalate "," (map varValueToText values)
+ src/Seihou/CLI/BlueprintMigration.hs view
@@ -0,0 +1,174 @@+-- | Pure selection/rendering and callback-driven execution for ordered+-- agent-guided blueprint migrations.+module Seihou.CLI.BlueprintMigration+  ( BlueprintMigrationLaunchFailure (..),+    BlueprintMigrationRunResult (..),+    renderBlueprintMigrationInstruction,+    renderBlueprintMigrationSystemPrompt,+    formatBlueprintMigrationDebugOutput,+    pendingBlueprintMigrations,+    runBlueprintMigrationsWith,+  )+where++import Data.Maybe (fromMaybe)+import Data.Text qualified as T+import Seihou.CLI.AgentLaunch+  ( AgentContext (..),+    formatAvailableModules,+    formatLocalModules,+    formatManifestState,+    formatModuleDhallState,+    formatSeihouProjectState,+    substitute,+  )+import Seihou.CLI.BlueprintExecution+  ( PreparedBlueprintExecution (..),+    renderBlueprintText,+  )+import Seihou.Core.Migration+  ( BlueprintMigration (..),+    BlueprintMigrationPlan (..),+  )+import Seihou.Core.Types+  ( AppliedBlueprintMigration (..),+    Blueprint (..),+    ModuleName (..),+    ResolvedVar,+    VarName,+  )+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)++-- | Terminal outcome for one pending migration chain.+data BlueprintMigrationRunResult+  = BlueprintMigrationNoWork+  | BlueprintMigrationComplete [BlueprintMigration]+  | BlueprintMigrationLaunchFailed BlueprintMigration BlueprintMigrationLaunchFailure+  | BlueprintMigrationRecordFailed BlueprintMigration 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.+renderBlueprintMigrationSystemPrompt ::+  Text ->+  AgentContext ->+  PreparedBlueprintExecution ->+  Int ->+  Int ->+  BlueprintMigration ->+  Text+renderBlueprintMigrationSystemPrompt template ctx prepared position total migration =+  let blueprint = prepared.preparedBlueprint+      renderedInstruction =+        renderBlueprintMigrationInstruction prepared.preparedResolvedVariables 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)),+          ("reference_files", prepared.preparedReferenceFiles),+          ("reference_files_dir", prepared.preparedReferenceFilesAccess),+          ("shared_prompt", prepared.preparedSharedPrompt),+          ("migration_prompt", renderedInstruction)+        ]+        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.+formatBlueprintMigrationDebugOutput ::+  (Int -> Int -> BlueprintMigration -> Text) ->+  [BlueprintMigration] ->+  Text+formatBlueprintMigrationDebugOutput render migrations =+  T.intercalate+    "\n\n"+    [ T.unlines+        [ "===== ["+            <> T.pack (show position)+            <> "/"+            <> T.pack (show total)+            <> "] "+            <> migration.from+            <> " -> "+            <> migration.to+            <> " =====",+          render position total migration+        ]+    | (position, migration) <- zip [1 ..] migrations+    ]+  where+    total = length migrations++-- | Remove exact-edge receipts while retaining planner order. Artifact+-- versions and timestamps are intentionally not part of the completion key.+pendingBlueprintMigrations ::+  Bool ->+  ModuleName ->+  [AppliedBlueprintMigration] ->+  BlueprintMigrationPlan ->+  [BlueprintMigration]+pendingBlueprintMigrations rerun blueprintName receipts plan+  | rerun = plan.blueprintPlanSteps+  | otherwise = filter (not . alreadyApplied) plan.blueprintPlanSteps+  where+    alreadyApplied migration =+      any+        ( \receipt ->+            receipt.name == blueprintName+              && receipt.fromVersion == migration.from+              && receipt.toVersion == migration.to+        )+        receipts++-- | Launch and record one pending edge at a time. A receipt is requested only+-- after its launch succeeds, and either callback failure stops the chain before+-- the next launch.+runBlueprintMigrationsWith ::+  (Int -> Int -> BlueprintMigration -> IO (Either BlueprintMigrationLaunchFailure ())) ->+  (BlueprintMigration -> IO (Either Text ())) ->+  [BlueprintMigration] ->+  IO BlueprintMigrationRunResult+runBlueprintMigrationsWith _launch _record [] = pure BlueprintMigrationNoWork+runBlueprintMigrationsWith launch record migrations =+  go [] (zip [1 ..] migrations)+  where+    total = length migrations++    go completed [] = pure (BlueprintMigrationComplete (reverse completed))+    go completed ((position, migration) : rest) = do+      launchResult <- launch position total migration+      case launchResult of+        Left failure -> pure (BlueprintMigrationLaunchFailed migration failure)+        Right () -> do+          recordResult <- record migration+          case recordResult of+            Left err -> pure (BlueprintMigrationRecordFailed migration err)+            Right () -> go (migration : completed) rest
+ src/Seihou/CLI/CommandExecution.hs view
@@ -0,0 +1,189 @@+module Seihou.CLI.CommandExecution+  ( CommandPolicy (..),+    CommandDisposition (..),+    PlannedCommand (..),+    CommandPlan (..),+    CommandPlanSummary (..),+    CommandExecutionError (..),+    planCommands,+    summarizeCommandPlan,+    executeCommandPlan,+    executeCommandPlanWithOutput,+    finalizeCommandReceipts,+  )+where++import Data.Map.Strict qualified as Map+import Data.Maybe (mapMaybe)+import Data.Time (UTCTime)+import Seihou.Core.CommandFingerprint (fingerprintCommand)+import Seihou.Core.Types+import Seihou.Effect.Process (Process, runProcess)+import Seihou.Prelude+import System.Exit (ExitCode (..))++-- | Whether a command phase runs every declaration, only declarations that+-- lack a successful receipt, or no declarations.+data CommandPolicy+  = RunAllCommands+  | RunChangedCommands+  | DisableCommands+  deriving stock (Eq, Show)++-- | The action selected for one rendered command.+data CommandDisposition+  = CommandWillRun+  | CommandSkippedUnchanged+  | CommandSkippedDisabled+  deriving stock (Eq, Show)++-- | A rendered command paired with its stable identity and selected action.+data PlannedCommand = PlannedCommand+  { operation :: Operation,+    fingerprint :: CommandFingerprint,+    disposition :: CommandDisposition+  }+  deriving stock (Eq, Show)++-- | An ordered command phase. Declaration/composition order is also execution+-- order.+newtype CommandPlan = CommandPlan+  { commands :: [PlannedCommand]+  }+  deriving stock (Eq, Show)++-- | Counts suitable for human or machine-readable previews.+data CommandPlanSummary = CommandPlanSummary+  { willRun :: Int,+    skippedUnchanged :: Int,+    skippedDisabled :: Int+  }+  deriving stock (Eq, Show)++-- | A failed shell command and its captured process result.+data CommandExecutionError = CommandExecutionError+  { command :: PlannedCommand,+    exitCode :: Int,+    stdout :: Text,+    stderr :: Text+  }+  deriving stock (Eq, Show)++-- | Select command dispositions according to policy and prior successful+-- receipts. Non-command operations are ignored.+planCommands ::+  CommandPolicy ->+  Map CommandFingerprint CommandReceipt ->+  [Operation] ->+  CommandPlan+planCommands policy priorReceipts =+  CommandPlan . mapMaybe planOne+  where+    planOne operation = do+      fingerprint <- fingerprintCommand operation+      let disposition = case policy of+            RunAllCommands -> CommandWillRun+            RunChangedCommands+              | Map.member fingerprint priorReceipts -> CommandSkippedUnchanged+              | otherwise -> CommandWillRun+            DisableCommands -> CommandSkippedDisabled+      pure PlannedCommand {operation, fingerprint, disposition}++-- | Count each command disposition without changing command order.+summarizeCommandPlan :: CommandPlan -> CommandPlanSummary+summarizeCommandPlan commandPlan =+  foldl' count emptySummary commandPlan.commands+  where+    emptySummary = CommandPlanSummary {willRun = 0, skippedUnchanged = 0, skippedDisabled = 0}+    count summary planned = case planned.disposition of+      CommandWillRun -> summary {willRun = summary.willRun + 1}+      CommandSkippedUnchanged -> summary {skippedUnchanged = summary.skippedUnchanged + 1}+      CommandSkippedDisabled -> summary {skippedDisabled = summary.skippedDisabled + 1}++-- | Execute runnable commands sequentially with @sh -c@. Stop at the first+-- failure. The caller receives receipts only if the entire phase succeeds.+executeCommandPlan ::+  (Process :> es) =>+  UTCTime ->+  CommandPlan ->+  Eff es (Either CommandExecutionError [CommandReceipt])+executeCommandPlan completedAt commandPlan = go [] commandPlan.commands+  where+    go = executeCommands completedAt (\_ _ _ -> pure ())++-- | Execute a command plan while exposing successful captured output to an+-- effectful callback. The executable uses this to preserve its historical+-- stdout behavior; library callers that only need receipts use+-- 'executeCommandPlan'.+executeCommandPlanWithOutput ::+  (Process :> es) =>+  UTCTime ->+  (PlannedCommand -> Text -> Text -> Eff es ()) ->+  CommandPlan ->+  Eff es (Either CommandExecutionError [CommandReceipt])+executeCommandPlanWithOutput completedAt onSuccess commandPlan =+  executeCommands completedAt onSuccess [] commandPlan.commands++executeCommands ::+  (Process :> es) =>+  UTCTime ->+  (PlannedCommand -> Text -> Text -> Eff es ()) ->+  [CommandReceipt] ->+  [PlannedCommand] ->+  Eff es (Either CommandExecutionError [CommandReceipt])+executeCommands completedAt onSuccess = go+  where+    go completed [] = pure (Right (reverse completed))+    go completed (planned : remaining) = case planned.disposition of+      CommandSkippedUnchanged -> go completed remaining+      CommandSkippedDisabled -> go completed remaining+      CommandWillRun -> case planned.operation of+        RunCommandOp {command, workDir, moduleName} -> do+          (processExit, stdout, stderr) <- runProcess "sh" ["-c", command] workDir+          case processExit of+            ExitSuccess -> do+              onSuccess planned stdout stderr+              let receipt =+                    CommandReceipt+                      { fingerprint = planned.fingerprint,+                        moduleName,+                        command,+                        workDir,+                        completedAt+                      }+              go (receipt : completed) remaining+            ExitFailure exitCode ->+              pure+                ( Left+                    CommandExecutionError+                      { command = planned,+                        exitCode,+                        stdout,+                        stderr+                      }+                )+        _ -> go completed remaining++-- | Produce the accepted receipt map for the plan's current declaration set.+-- Removed commands are dropped. Fresh successes replace old receipts;+-- unchanged or explicitly disabled declarations retain a matching old receipt.+finalizeCommandReceipts ::+  CommandPlan ->+  [CommandReceipt] ->+  Map CommandFingerprint CommandReceipt ->+  Map CommandFingerprint CommandReceipt+finalizeCommandReceipts commandPlan completed priorReceipts =+  Map.fromList (mapMaybe receiptFor commandPlan.commands)+  where+    completedByFingerprint = Map.fromList [(receipt.fingerprint, receipt) | receipt <- completed]++    receiptFor planned =+      case Map.lookup planned.fingerprint completedByFingerprint of+        Just receipt -> Just (planned.fingerprint, receipt)+        Nothing -> case planned.disposition of+          CommandWillRun -> Nothing+          CommandSkippedUnchanged -> retainPrior planned+          CommandSkippedDisabled -> retainPrior planned++    retainPrior planned =+      (planned.fingerprint,) <$> Map.lookup planned.fingerprint priorReceipts
src/Seihou/CLI/CommitMessage.hs view
@@ -88,6 +88,6 @@         _ -> txt  fallbackMessage :: [ModuleName] -> T.Text-fallbackMessage [] = "seihou: apply modules"-fallbackMessage [m] = "seihou: apply module " <> m.unModuleName-fallbackMessage ms = "seihou: apply modules " <> T.intercalate ", " (map (.unModuleName) ms)+fallbackMessage [] = "chore(seihou): apply modules"+fallbackMessage [m] = "chore(seihou): apply " <> m.unModuleName+fallbackMessage ms = "chore(seihou): apply " <> T.intercalate ", " (map (.unModuleName) ms)
src/Seihou/CLI/PendingMigrations.hs view
@@ -72,7 +72,10 @@   T.unlines $     "Pending migrations detected:"       : map renderEntry pendings-      ++ ["", "Run 'seihou migrate <module>' for each, or pass --with-migrations to apply during this run."]+      ++ [ "",+           "For a recorded project application, run 'seihou update <target>'.",+           "For focused recovery, run 'seihou migrate <module>' for each, or pass --with-migrations to this explicit reconfiguration run."+         ]   where     renderEntry (name, plan) =       "  "
src/Seihou/CLI/SchemaVersion.hs view
@@ -9,11 +9,11 @@  -- | Raw URL for the seihou-schema package.dhall at a pinned commit schemaUrl :: Text-schemaUrl = "https://raw.githubusercontent.com/shinzui/seihou-schema/a0fba0d17b43b14bfdf6d0bf98f1b7ff7af4ebab/package.dhall"+schemaUrl = "https://raw.githubusercontent.com/shinzui/seihou-schema/2dffa0592be47835a60784b89a289226ba990aa8/package.dhall"  -- | SHA256 integrity hash for the schema import schemaHash :: Text-schemaHash = "sha256:36250d32d50cec0ea8c74926684ffb8b20f6d0b4f2152930dfa04a1ff108ef3f"+schemaHash = "sha256:01b6f873520459f3958baa34d3f97a49a4263b9a7225a758cddca5ab3a911f61"  -- | Complete Dhall import line for use in generated modules schemaImportLine :: Text
src/Seihou/CLI/StatusRender.hs view
@@ -1,13 +1,15 @@ module Seihou.CLI.StatusRender   ( formatStatus,+    formatBlueprintMigrations,     ModuleAdvice (..),-    moduleAdvice,   ) where -import Data.List (intersperse)+import Data.List (intersperse, nub) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map+import Data.Maybe (isJust, listToMaybe, mapMaybe)+import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as T import Data.Time.Format (defaultTimeLocale, formatTime)@@ -19,8 +21,12 @@ import Seihou.Core.Migration (MigrationPlan (..)) import Seihou.Core.Types   ( AppliedBlueprint (..),+    AppliedBlueprintMigration (..),+    AppliedComposition (..),+    AppliedInstanceState (..),     AppliedModule (..),     AppliedRecipe (..),+    AppliedTarget (..),     Manifest (..),     ModuleName (..),     ParentVars (..),@@ -31,41 +37,15 @@   ) import Seihou.Core.Version (renderVersion) --- | What action a single applied-module row recommends.------ Order of precedence: a pending migration always wins over a bare--- "outdated" annotation, because @seihou migrate@ (after EP-2) is--- self-contained and one command suffices to bring the project to the--- new version. An outdated row with no detected pending plan falls--- back to @seihou upgrade@.+-- | What action an applied module or recorded application recommends.+-- Pending migrations and ordinary version drift both route through the+-- project-aware update workflow. data ModuleAdvice-  = -- | Nothing pending; do not emit a hint.-    AdviceNone-  | -- | Module is outdated and no pending migration was detected. The-    -- renderer prints @"Run: seihou upgrade <name>"@.-    AdviceUpgradeOnly Text-  | -- | A pending migration was detected. The carried 'MigrationPlan'-    -- describes the version range and the in-window migrations that-    -- would run; 'planSteps' may be empty (a pure version bump) or-    -- non-empty. The renderer prints a one-line summary and-    -- @"Run: seihou migrate <name>"@.-    AdvicePendingMigration Text MigrationPlan+  = AdviceNone+  | AdviceProjectUpdate Text (Maybe MigrationPlan)+  | AdviceProjectUpdateAll   deriving stock (Eq, Show) --- | Decide which advice to emit for a single applied module. Any--- detected pending plan wins over a bare outdated annotation because--- @seihou migrate@ (after EP-2) is self-contained.-moduleAdvice ::-  AppliedModule ->-  Maybe OutdatedStatus ->-  Maybe MigrationPlan ->-  ModuleAdvice-moduleAdvice am mStatus mPlan = case mPlan of-  Just plan -> AdvicePendingMigration am.name.unModuleName plan-  Nothing -> case mStatus of-    Just OutdatedSt -> AdviceUpgradeOnly am.name.unModuleName-    _ -> AdviceNone- -- | Render the full @seihou status@ output as a single 'Text' value. -- -- @color@ controls ANSI styling; pass 'False' for plain text (used by@@ -82,6 +62,7 @@     ["Seihou Status:", ""]       ++ recipeSection manifest       ++ blueprintSection manifest+      ++ formatBlueprintMigrations manifest.blueprintMigrations       ++ appliedSection color manifest mEntries pendings       ++ trackedSection color tracked       ++ varsSection manifest@@ -93,19 +74,7 @@       Nothing -> Map.empty     pendingMap =       Map.fromList [(name.unModuleName, plan) | (name, plan) <- pendings]-    adviceList = map (rowAdvice entryMap pendingMap) manifest.modules---- | Build a 'ModuleAdvice' for one applied module from the lookup maps.-rowAdvice ::-  Map Text OutdatedEntry ->-  Map Text MigrationPlan ->-  AppliedModule ->-  ModuleAdvice-rowAdvice entryMap pendingMap am =-  let name = am.name.unModuleName-      mStatus = (.status) <$> Map.lookup name entryMap-      mPlan = Map.lookup name pendingMap-   in moduleAdvice am mStatus mPlan+    adviceList = projectAdviceList manifest entryMap pendingMap  -- --------------------------------------------------------------------------- -- Section renderers@@ -145,6 +114,27 @@           Just p -> ["  Prompt: \"" <> p <> "\""]      in [header, baselineLine] ++ promptLines ++ [""] +-- | Render durable agent-guided migration receipts. An empty ledger adds no+-- output; each populated row includes the artifact, exact edge, and timestamp.+formatBlueprintMigrations :: [AppliedBlueprintMigration] -> [Text]+formatBlueprintMigrations [] = []+formatBlueprintMigrations receipts =+  "Blueprint migrations:"+    : map renderReceipt receipts+      <> [""]+  where+    renderReceipt receipt =+      "  "+        <> receipt.name.unModuleName+        <> maybe "" (\version -> " v" <> version) receipt.blueprintVersion+        <> ": "+        <> receipt.fromVersion+        <> " -> "+        <> receipt.toVersion+        <> " (applied "+        <> T.pack (formatTime defaultTimeLocale "%Y-%m-%d %H:%M UTC" receipt.appliedAt)+        <> ")"+ -- | Render the baseline body for the blueprint section. Three cases: -- @--no-baseline@ was passed, the blueprint declared no baseline at -- all, or one or more baseline modules were applied.@@ -173,16 +163,17 @@       Map.fromList [(name.unModuleName, plan) | (name, plan) <- pendings]     moduleLines       | null manifest.modules = ["  (none)"]-      | otherwise =-          concatMap-            ( \am ->-                let advice = rowAdvice entryMap pendingMap am-                    annotation = lookupEntry mEntries entryMap am-                    headerLine = formatModuleLine color annotation am-                    hintLines = formatAdvice color advice-                 in headerLine : hintLines-            )-            manifest.modules+      | otherwise = renderRows Set.empty manifest.modules+    renderRows _ [] = []+    renderRows seen (am : rest) =+      let name = am.name.unModuleName+          annotation = lookupEntry mEntries entryMap am+          headerLine = formatModuleLine color annotation am+          hintLines+            | Set.member name seen = []+            | null manifest.applications = formatAdvice color (rowProjectAdvice entryMap pendingMap am)+            | otherwise = maybe [] (formatPendingDetail color) (Map.lookup name pendingMap)+       in headerLine : hintLines <> renderRows (Set.insert name seen) rest  trackedSection :: Bool -> [TrackedFile] -> [Text] trackedSection color tracked =@@ -217,14 +208,14 @@ -- actionable problem. recommendedActionsSection :: [ModuleAdvice] -> [Text] recommendedActionsSection advices =-  case [c | Just c <- map adviceCommand advices] of+  case nub [c | Just c <- map adviceCommand advices] of     [] -> []     cmds -> ["", "Recommended actions:"] ++ map ("  " <>) cmds  adviceCommand :: ModuleAdvice -> Maybe Text adviceCommand AdviceNone = Nothing-adviceCommand (AdviceUpgradeOnly name) = Just ("seihou upgrade " <> name)-adviceCommand (AdvicePendingMigration name _) = Just ("seihou migrate " <> name)+adviceCommand (AdviceProjectUpdate target _) = Just ("seihou update " <> target)+adviceCommand AdviceProjectUpdateAll = Just "seihou update"  -- --------------------------------------------------------------------------- -- Per-row formatting@@ -287,23 +278,81 @@ -- under the module name. formatAdvice :: Bool -> ModuleAdvice -> [Text] formatAdvice _ AdviceNone = []-formatAdvice color (AdviceUpgradeOnly name) =-  ["    " <> applyColor color yellow ("Run: seihou upgrade " <> name)]-formatAdvice color (AdvicePendingMigration name plan) =-  ["    " <> applyColor color yellow (planSummary name plan)]+formatAdvice color (AdviceProjectUpdate target Nothing) =+  ["    " <> applyColor color yellow ("Run: seihou update " <> target)]+formatAdvice color (AdviceProjectUpdate target (Just plan)) =+  ["    " <> applyColor color yellow (projectPlanSummary target plan)]+formatAdvice color AdviceProjectUpdateAll =+  ["    " <> applyColor color yellow "Run: seihou update"] --- | Format the "Pending migration: from -> to (N step(s)). Run:--- seihou migrate <name>" line for a pending-migration advice row.-planSummary :: Text -> MigrationPlan -> Text-planSummary name plan =+projectPlanSummary :: Text -> MigrationPlan -> Text+projectPlanSummary target plan =   "Pending migration: "     <> renderVersion plan.planFrom     <> " -> "     <> renderVersion plan.planTo     <> " ("     <> T.pack (show (length plan.planSteps))-    <> " step(s)). Run: seihou migrate "-    <> name+    <> " step(s)). Run: seihou update "+    <> target++formatPendingDetail :: Bool -> MigrationPlan -> [Text]+formatPendingDetail color plan =+  [ "    "+      <> applyColor+        color+        yellow+        ( "Pending migration: "+            <> renderVersion plan.planFrom+            <> " -> "+            <> renderVersion plan.planTo+            <> " ("+            <> T.pack (show (length plan.planSteps))+            <> " step(s))"+        )+  ]++rowProjectAdvice ::+  Map Text OutdatedEntry ->+  Map Text MigrationPlan ->+  AppliedModule ->+  ModuleAdvice+rowProjectAdvice entryMap pendingMap applied =+  if actionable+    then AdviceProjectUpdate name pending+    else AdviceNone+  where+    name = applied.name.unModuleName+    pending = Map.lookup name pendingMap+    outdated = maybe False ((== OutdatedSt) . (.status)) (Map.lookup name entryMap)+    actionable = outdated || isJust pending++projectAdviceList ::+  Manifest ->+  Map Text OutdatedEntry ->+  Map Text MigrationPlan ->+  [ModuleAdvice]+projectAdviceList manifest entryMap pendingMap+  | null manifest.applications =+      map (rowProjectAdvice entryMap pendingMap) (deduplicateModules manifest.modules)+  | otherwise =+      let applicationAdvice = mapMaybe adviceForApplication manifest.applications+       in applicationAdvice <> [AdviceProjectUpdateAll | length applicationAdvice > 1]+  where+    adviceForApplication application =+      let names = map (.name.unModuleName) application.instances+          pending = listToMaybe (mapMaybe (`Map.lookup` pendingMap) names)+          outdated = any (maybe False ((== OutdatedSt) . (.status)) . (`Map.lookup` entryMap)) names+       in if outdated || isJust pending+            then Just (AdviceProjectUpdate (targetText application.target) pending)+            else Nothing++deduplicateModules :: [AppliedModule] -> [AppliedModule]+deduplicateModules = Map.elems . Map.fromList . map (\applied -> (applied.name.unModuleName, applied))++targetText :: AppliedTarget -> Text+targetText (AppliedModuleTarget name) = name.unModuleName+targetText (AppliedRecipeTarget name) = name.unRecipeName  renderEntry :: Bool -> OutdatedEntry -> Text renderEntry color e = case e.status of
src/Seihou/CLI/Style.hs view
@@ -87,8 +87,10 @@ renderNonFileColor :: PreviewLine -> Text renderNonFileColor (DirPreview path) =   "    " <> cyan "mkdir" <> "  " <> cyan (T.pack path)-renderNonFileColor (CommandPreview cmd) =-  "    " <> dim "run" <> "    " <> dim cmd+renderNonFileColor (CommandPreview cmd mOwner) =+  "    " <> dim "run" <> "    " <> dim cmd <> ownerSuffix mOwner+  where+    ownerSuffix = maybe "" (\owner -> "  " <> dim ("(" <> owner.unModuleName <> ")")) renderNonFileColor (OrphanPreview path modName') =   "    " <> magenta "[orphaned]" <> "  " <> magenta (T.pack path) <> "  " <> dim ("(orphaned from " <> modName'.unModuleName <> ")") renderNonFileColor _ = ""
+ src/Seihou/CLI/Update.hs view
@@ -0,0 +1,1068 @@+module Seihou.CLI.Update+  ( UpdateSelection (..),+    PromptPolicy (..),+    UpdateRequest (..),+    VersionChange (..),+    InputChangeSummary (..),+    CandidateArtifactKind (..),+    CandidateArtifact (..),+    PlannedUpdateMigration (..),+    UpdateWarning (..),+    UpdatePlan (..),+    CommandSummary (..),+    UpdateResult (..),+    UpdateError (..),+    planProjectUpdate,+    applyProjectUpdate,+    withProjectUpdate,+    isUpdateNoOp,+  )+where++import Control.Exception (SomeException, displayException, try)+import Control.Monad (foldM, forM, forM_, when)+import Data.Foldable (traverse_)+import Data.List (find, isPrefixOf)+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe, isJust, mapMaybe, maybeToList)+import Data.Set qualified as Set+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Data.Time (UTCTime, getCurrentTime)+import Effectful (runEff)+import Seihou.CLI.CommandExecution+import Seihou.CLI.InstallShared (installModuleDir)+import Seihou.CLI.Shared (deriveNamespace, toVarNameMap)+import Seihou.CLI.Update.Migrations+import Seihou.CLI.Update.Recovery+import Seihou.CLI.Update.Selection+import Seihou.CLI.Update.Source+import Seihou.CLI.Update.Types+import Seihou.Composition.Instance (ModuleInstance (..))+import Seihou.Composition.Plan (compileComposedPlan)+import Seihou.Composition.Recipe (expandRecipe)+import Seihou.Composition.Resolve+  ( PromptPermission (..),+    SavedInstanceValues,+    loadComposition,+    resolveWithPromptPermission,+  )+import Seihou.Core.Application (buildAppliedComposition, replaceAppliedComposition)+import Seihou.Core.Module (defaultSearchPaths, discoverRunnable)+import Seihou.Core.Types+import Seihou.Core.Version (parseVersion)+import Seihou.Effect.BaselineStore (pruneBaselines)+import Seihou.Effect.BaselineStoreInterp (runBaselineStore)+import Seihou.Effect.ConfigReader+import Seihou.Effect.ConfigReaderInterp (runConfigReader)+import Seihou.Effect.ConsoleInterp (runConsole)+import Seihou.Effect.FilesystemInterp (runFilesystem)+import Seihou.Effect.FilesystemPure (PureFS (..))+import Seihou.Effect.ManifestStore (readManifest, writeManifest)+import Seihou.Effect.ManifestStoreInterp (runManifestStore)+import Seihou.Effect.ProcessInterp (runProcessIO)+import Seihou.Engine.Baseline (manifestBaselineRefs)+import Seihou.Engine.Migrate (ExecutedMigrationPlan (..), MigrationOpInstance (..), classifyMigration, executeMigration)+import Seihou.Engine.Reconcile+import Seihou.Engine.UpdateTransaction+import Seihou.Manifest.Hash (hashContent)+import Seihou.Manifest.Types (currentManifestVersion)+import Seihou.Prelude+import System.Directory qualified as Directory+import System.Environment (getEnvironment)+import System.FilePath (takeDirectory)+import System.IO.Temp (createTempDirectory, getCanonicalTemporaryDirectory, withSystemTempDirectory)++-- | Lifetime-safe public planning entry point. Candidate clones exist for the+-- callback only, so an 'UpdatePlan' cannot accidentally outlive its sources.+withProjectUpdate ::+  UpdateRequest ->+  (Either UpdateError UpdatePlan -> IO a) ->+  IO a+withProjectUpdate request callback =+  withSystemTempDirectory "seihou-project-update" $ \sessionDirectory ->+    planProjectUpdateIn sessionDirectory request >>= callback++-- | Internal/test-facing planner. Prefer 'withProjectUpdate'; this form keeps+-- its temporary session until process exit or a test removes it.+planProjectUpdate :: UpdateRequest -> IO (Either UpdateError UpdatePlan)+planProjectUpdate request = do+  temporaryRoot <- getCanonicalTemporaryDirectory+  sessionDirectory <- createTempDirectory temporaryRoot "seihou-project-update"+  result <- planProjectUpdateIn sessionDirectory request+  case result of+    Left _ -> Directory.removePathForcibly sessionDirectory+    Right _ -> pure ()+  pure result++planProjectUpdateIn :: FilePath -> UpdateRequest -> IO (Either UpdateError UpdatePlan)+planProjectUpdateIn sessionDirectory request = do+  projectRoot <- Directory.getCurrentDirectory+  installedDirectory <- standardInstalledDirectory+  recovery <- recoverAtEntry projectRoot+  case recovery of+    Left err -> pure (Left err)+    Right () -> do+      let manifestPath = projectRoot </> ".seihou" </> "manifest.json"+          baselineDirectory = projectRoot </> ".seihou" </> "baselines"+      manifestResult <- readManifestIO manifestPath+      case manifestResult of+        Left err -> pure (Left err)+        Right manifest -> do+          now <- getCurrentTime+          seeded <- selectAndSeedLegacy request manifest now+          case seeded of+            Left err -> pure (Left err)+            Right (selected, seedWarnings) -> do+              staged <- stageCandidateSources sessionDirectory selected+              case staged of+                Left err -> pure (Left err)+                Right (catalog, sourceWarnings) -> do+                  plannedApplicationsResult <- traverse (planApplication request installedDirectory catalog now) selected+                  case sequence plannedApplicationsResult of+                    Left err -> pure (Left err)+                    Right plannedApplications -> do+                      let applicationInputs =+                            [ (Just previous, planned.modulesInOrder)+                            | (previous, planned) <- zip selected plannedApplications+                            ]+                      stagedMigrations <- planAndStageMigrations projectRoot manifest catalog applicationInputs+                      case stagedMigrations of+                        Left err -> pure (Left err)+                        Right migrationStage -> do+                          let (operations, owners, compositionWarnings) = combineApplicationPlans plannedApplications+                              selectedIds = Set.fromList (map (.candidate.applicationId) plannedApplications)+                          stageRoot <- materializeStagedProject sessionDirectory projectRoot migrationStage.filesystem operations+                          reconciliationResult <-+                            runEff $+                              runFilesystem $+                                runBaselineStore baselineDirectory $+                                  planReconciliation stageRoot migrationStage.manifest selectedIds operations owners+                          case reconciliationResult of+                            Left err -> pure (Left (UpdateReconciliationFailed err))+                            Right reconciliation -> do+                              evidence <- versionEvidence catalog selected plannedApplications+                              case evidence of+                                Left err -> pure (Left err)+                                Right (versionChanges, versionWarnings) -> do+                                  let usedArtifacts = artifactsUsedBy catalog plannedApplications+                                      priorReceipts = Map.unions (map (.commandReceipts) selected)+                                      commandPlan = planCommands request.commandPolicy priorReceipts operations+                                      warnings =+                                        seedWarnings+                                          <> sourceWarnings+                                          <> migrationStage.warnings+                                          <> compositionWarnings+                                          <> versionWarnings+                                      inputChanges = summarizeInputChanges seedWarnings plannedApplications+                                      transactionTargets = transactionTargetPaths manifest reconciliation migrationStage.plans+                                  observedProjectHashes <-+                                    observePaths+                                      projectRoot+                                      (Set.insert (".seihou" </> "manifest.json") transactionTargets)+                                  let snapshot =+                                        UpdateSnapshot+                                          { sessionDirectory,+                                            projectRoot,+                                            manifestPath,+                                            baselineDirectory,+                                            installedDirectory,+                                            originalManifest = manifest,+                                            candidateHashes = Map.fromList [(artifact.originalDirectory, artifact.contentHash) | artifact <- usedArtifacts],+                                            observedProjectHashes,+                                            transactionTargets+                                          }+                                  pure+                                    ( Right+                                        UpdatePlan+                                          { applications = map (.candidate) plannedApplications,+                                            versionChanges,+                                            inputChanges,+                                            migrations = migrationStage.plans,+                                            reconciliation,+                                            commandPlan,+                                            candidateArtifacts = usedArtifacts,+                                            warnings,+                                            request,+                                            snapshot,+                                            plannedApplications+                                          }+                                    )++applyProjectUpdate :: UpdatePlan -> IO (Either UpdateError UpdateResult)+applyProjectUpdate plan =+  Directory.withCurrentDirectory plan.snapshot.projectRoot $ do+    recovery <- recoverAtEntry plan.snapshot.projectRoot+    case recovery of+      Left err -> pure (Left err)+      Right () -> do+        stale <- stalePlanPaths plan+        if not (Set.null stale)+          then pure (Left (UpdatePlanStale stale))+          else+            if plan.request.dryRun+              then pure (Right (dryRunResult plan))+              else+                if not (Set.null (unresolvedPaths plan.reconciliation))+                  then pure (Left (UpdateHasUnresolvedPaths (unresolvedPaths plan.reconciliation)))+                  else+                    if isStructuredNoOp plan+                      then pure (Right (noOpResult plan))+                      else applyAcceptedPlan plan++applyAcceptedPlan :: UpdatePlan -> IO (Either UpdateError UpdateResult)+applyAcceptedPlan plan = do+  transactionResult <- beginUpdateTransaction plan.snapshot.projectRoot plan.snapshot.transactionTargets+  case transactionResult of+    Left err -> pure (Left (UpdateTransactionFailed err))+    Right transaction -> do+      backupResult <- prepareServiceBackups transaction plan.snapshot.installedDirectory plan.migrations plan.candidateArtifacts+      case backupResult of+        Left err -> abortUpdate transaction err+        Right () -> do+          now <- getCurrentTime+          migrated <- runRealMigrations now plan.snapshot.originalManifest plan.migrations+          case migrated of+            Left err -> abortUpdate transaction err+            Right migratedManifest -> do+              actualReconciliation <- planActualReconciliation plan migratedManifest+              case actualReconciliation of+                Left err -> abortUpdate transaction err+                Right actual -> case reapplyPlannedResolutions plan.reconciliation actual of+                  Left _ ->+                    abortUpdate+                      transaction+                      ( UpdateChangedAfterMigrationCommand+                          (reconciliationSummary plan.reconciliation)+                          (reconciliationSummary actual)+                      )+                  Right resolvedActual+                    | resolvedActual /= plan.reconciliation ->+                        abortUpdate+                          transaction+                          ( UpdateChangedAfterMigrationCommand+                              (reconciliationSummary plan.reconciliation)+                              (reconciliationSummary resolvedActual)+                          )+                    | otherwise -> do+                        let reconciliationManifest = migratedManifest {genAt = now}+                        appliedFiles <- applyReconciliation transaction resolvedActual reconciliationManifest+                        case appliedFiles of+                          Left err -> abortUpdate transaction (UpdateTransactionFailed err)+                          Right filesManifest -> do+                            commandResult <-+                              runEff $+                                runProcessIO $+                                  executeCommandPlan now plan.commandPlan+                            case commandResult of+                              Left err ->+                                abortUpdate+                                  transaction+                                  (UpdateCommandFailed err [ArbitraryCommandSideEffectsMayRemain])+                              Right completedReceipts -> do+                                let finalManifest = buildFinalManifest now plan filesManifest completedReceipts+                                markerResult <- setCommitMarkers transaction finalManifest+                                case markerResult of+                                  Left err -> abortUpdate transaction err+                                  Right () -> do+                                    publication <- publishCandidates plan.candidateArtifacts+                                    case publication of+                                      Left err -> abortUpdate transaction err+                                      Right () -> do+                                        written <- writeManifestIO plan.snapshot.manifestPath finalManifest+                                        case written of+                                          Left err -> abortUpdate transaction err+                                          Right () -> finishCommitted transaction plan finalManifest completedReceipts++planApplication ::+  UpdateRequest ->+  FilePath ->+  CandidateCatalog ->+  UTCTime ->+  AppliedComposition ->+  IO (Either UpdateError PlannedApplication)+planApplication request installedDirectory catalog now previous = do+  fallback <- defaultSearchPaths+  case candidateRoot catalog previous of+    Left err -> pure (Left err)+    Right (primary, recipeAdditional, recipeOverrides, targetArtifact) -> do+      let allAdditional = recipeAdditional <> previous.additionalModules+      loaded <- loadComposition (catalog.searchRoot : fallback) primary allAdditional+      case loaded of+        Left err -> pure (Left (CandidateLoadFailed primary.unModuleName err))+        Right modulesInOrder -> do+          let savedValues+                | request.reconfigure = Map.empty+                | otherwise = savedInstanceValues previous+              namespace = fromMaybe (deriveNamespace primary) previous.namespace+              context = fromMaybe "" previous.context+          resolved <- resolveApplicationValues request namespace context savedValues recipeOverrides modulesInOrder+          case resolved of+            Left err -> pure (Left err)+            Right resolvedValues -> do+              compiled <-+                compileComposedPlan+                  [ (instanceId, modul, directory, Map.map (.value) (resolvedValues Map.! instanceId))+                  | (instanceId, modul, directory) <- modulesInOrder+                  ]+              case compiled of+                Left errors -> pure (Left (UpdateCompositionFailed errors))+                Right (operations, compositionWarnings, rawOwners) -> do+                  let targetSource = publishedArtifactSource installedDirectory targetArtifact+                      candidate0 =+                        ( buildAppliedComposition+                            previous.target+                            targetSource+                            targetArtifact.version+                            previous.additionalModules+                            (Just namespace)+                            previous.context+                            modulesInOrder+                            resolvedValues+                            now+                        )+                          { applicationId = previous.applicationId+                          }+                      candidate =+                        setCompositionState+                          (map (publishInstanceSource installedDirectory catalog) candidate0.instances)+                          previous.commandReceipts+                          candidate0+                      desiredOwners =+                        Map.map+                          (\owner -> DesiredFileOwner owner (Set.singleton candidate.applicationId))+                          rawOwners+                      renderedWarnings = map compositionWarning compositionWarnings+                  pure+                    ( Right+                        PlannedApplication+                          { previous = Just previous,+                            candidate,+                            modulesInOrder,+                            resolvedValues,+                            operations,+                            desiredOwners+                          }+                    )+  where+    compositionWarning (FileOverwritten path old new) = CrossApplicationLastWriter path old new+    compositionWarning (ContentMerged path old new) = CrossApplicationLastWriter path old new++candidateRoot ::+  CandidateCatalog ->+  AppliedComposition ->+  Either UpdateError (ModuleName, [ModuleName], Map VarName Text, CandidateArtifact)+candidateRoot catalog previous = case previous.target of+  AppliedModuleTarget name -> do+    artifact <- lookupArtifact catalog CandidateModule name.unModuleName+    Right (name, [], Map.empty, artifact)+  AppliedRecipeTarget name -> do+    artifact <- lookupArtifact catalog CandidateRecipe name.unRecipeName+    recipe <- maybe (Left (CandidateArtifactMissing CandidateRecipe name.unRecipeName)) Right artifact.recipeDefinition+    (primary, additional, overrides, _, _) <- first (CandidateRepositoryInvalid name.unRecipeName) (expandRecipe recipe)+    Right (primary, additional, overrides, artifact)++lookupArtifact :: CandidateCatalog -> CandidateArtifactKind -> Text -> Either UpdateError CandidateArtifact+lookupArtifact catalog kind name =+  maybe (Left (CandidateArtifactMissing kind name)) Right (Map.lookup (kind, name) catalog.artifacts)++resolveApplicationValues ::+  UpdateRequest ->+  Text ->+  Text ->+  SavedInstanceValues ->+  Map VarName Text ->+  [(ModuleInstance, Module, FilePath)] ->+  IO (Either UpdateError (Map ModuleInstance (Map VarName ResolvedVar)))+resolveApplicationValues request namespace context saved recipeOverrides modulesInOrder = do+  envPairs <- getEnvironment+  configs <- loadConfigMaps namespace context+  case configs of+    Left err -> pure (Left err)+    Right (localConfig, namespaceConfig, contextConfig, globalConfig) -> do+      let cli = Map.fromList [(VarName name, value) | (name, value) <- request.varOverrides]+          overrides = Map.union cli recipeOverrides+          env = Map.fromList [(T.pack key, T.pack value) | (key, value) <- envPairs]+          promptPermission = case request.promptPolicy of+            AllowPrompts -> PromptsAllowed+            ForbidPrompts -> PromptsForbidden+      result <-+        runEff $+          runConsole $+            resolveWithPromptPermission+              promptPermission+              modulesInOrder+              saved+              overrides+              env+              namespace+              context+              localConfig+              namespaceConfig+              contextConfig+              globalConfig+      pure (first UpdateVariableErrors result)++loadConfigMaps :: Text -> Text -> IO (Either UpdateError (Map VarName Text, Map VarName Text, Map VarName Text, Map VarName Text))+loadConfigMaps namespace context =+  runEff $ runConfigReader $ do+    local <- readLocalConfig+    namespaceValues <- readNamespaceConfig namespace+    contextValues <- readContextConfig context+    global <- readGlobalConfig+    pure $ do+      local' <- configResult local+      namespace' <- configResult namespaceValues+      context' <- configResult contextValues+      global' <- configResult global+      Right (toVarNameMap local', toVarNameMap namespace', toVarNameMap context', toVarNameMap global')+  where+    configResult = first (UpdateConfigurationFailed . T.pack . show)++selectAndSeedLegacy ::+  UpdateRequest ->+  Manifest ->+  UTCTime ->+  IO (Either UpdateError ([AppliedComposition], [UpdateWarning]))+selectAndSeedLegacy request manifest now = case selectApplications request.selection manifest of+  Left err -> pure (Left err)+  Right (RecordedSelection selected) -> pure (Right (selected, []))+  Right (LegacySelection name) -> seedLegacyApplication request manifest now name++seedLegacyApplication ::+  UpdateRequest -> Manifest -> UTCTime -> Text -> IO (Either UpdateError ([AppliedComposition], [UpdateWarning]))+seedLegacyApplication request manifest now requested = do+  searchPaths <- defaultSearchPaths+  discovered <- discoverRunnable searchPaths (ModuleName requested)+  case discovered of+    Left err -> pure (Left (CandidateLoadFailed requested err))+    Right runnable -> do+      let root = case runnable of+            RunnableModule modul directory -> Right (AppliedModuleTarget modul.name, modul.name, [], Map.empty, directory, modul.version)+            RunnableRecipe recipe directory -> do+              (primary, additional, overrides, _, _) <- first (CandidateRepositoryInvalid requested) (expandRecipe recipe)+              Right (AppliedRecipeTarget recipe.name, primary, additional, overrides, directory, recipe.version)+            _ -> Left (CandidateArtifactMissing CandidateModule requested)+      case root of+        Left err -> pure (Left err)+        Right (target, primary, additional, recipeOverrides, targetSource, targetVersion) -> do+          loaded <- loadComposition searchPaths primary additional+          case loaded of+            Left err -> pure (Left (CandidateLoadFailed requested err))+            Right modulesInOrder -> do+              let (saved, warnings) = legacySavedValues manifest modulesInOrder+                  namespace = deriveNamespace primary+              resolved <- resolveApplicationValues request namespace "" saved recipeOverrides modulesInOrder+              case resolved of+                Left err -> pure (Left err)+                Right resolvedValues -> do+                  let provisional0 =+                        buildAppliedComposition target targetSource targetVersion [] (Just namespace) Nothing modulesInOrder resolvedValues now+                      provisional = provisional0 {instances = map (restoreLegacyVersion manifest) provisional0.instances}+                  pure (Right ([provisional], warnings))++legacySavedValues ::+  Manifest ->+  [(ModuleInstance, Module, FilePath)] ->+  (SavedInstanceValues, [UpdateWarning])+legacySavedValues manifest modulesInOrder = (saved, warnings)+  where+    declarations = [(instanceId, declaration) | (instanceId, modul, _) <- modulesInOrder, declaration <- modul.vars]+    counts = Map.fromListWith (+) [(declaration.name, 1 :: Int) | (_, declaration) <- declarations]+    saved =+      Map.fromListWith+        Map.union+        [ (instanceId, Map.singleton declaration.name value)+        | (instanceId, declaration) <- declarations,+          Map.lookup declaration.name counts == Just 1,+          Just value <- [Map.lookup declaration.name manifest.vars]+        ]+    ambiguous =+      [ AmbiguousLegacyValue name+      | (name, count) <- Map.toAscList counts,+        count > 1,+        Map.member name manifest.vars+      ]+    missing =+      [ MissingLegacyValue declaration.name+      | (_, declaration) <- declarations,+        declaration.required,+        Map.notMember declaration.name manifest.vars+      ]+    warnings = ambiguous <> missing++restoreLegacyVersion :: Manifest -> AppliedInstanceState -> AppliedInstanceState+restoreLegacyVersion manifest state =+  case find (\applied -> applied.name == state.name && applied.parentVars == state.parentVars) manifest.modules of+    Nothing -> state+    Just applied ->+      AppliedInstanceState+        { name = state.name,+          parentVars = state.parentVars,+          source = applied.source,+          moduleVersion = applied.moduleVersion,+          resolvedVars = state.resolvedVars+        }++savedInstanceValues :: AppliedComposition -> SavedInstanceValues+savedInstanceValues application =+  Map.fromList+    [ (ModuleInstance state.name state.parentVars, state.resolvedVars)+    | state <- application.instances+    ]++combineApplicationPlans ::+  [PlannedApplication] ->+  ([Operation], Map FilePath DesiredFileOwner, [UpdateWarning])+combineApplicationPlans = foldl' addApplication ([], Map.empty, [])+  where+    addApplication (operations, owners, warnings) application =+      let crossWarnings =+            [ CrossApplicationLastWriter path prior.moduleName next.moduleName+            | (path, next) <- Map.toAscList application.desiredOwners,+              Just prior <- [Map.lookup path owners],+              prior.moduleName /= next.moduleName+            ]+          mergedOwners = Map.unionWith mergeOwner application.desiredOwners owners+       in (operations <> application.operations, mergedOwners, warnings <> crossWarnings)+    mergeOwner newest prior =+      DesiredFileOwner newest.moduleName (Set.union newest.applicationIds prior.applicationIds)++materializeStagedProject :: FilePath -> FilePath -> PureFS -> [Operation] -> IO FilePath+materializeStagedProject sessionDirectory projectRoot filesystem operations = do+  let stageRoot = sessionDirectory </> "staged-project"+  Directory.createDirectoryIfMissing True stageRoot+  forM_ (Map.toAscList filesystem.files) $ \(path, content) -> writeStageFile stageRoot path content+  forM_ (Set.toAscList filesystem.dirs) $ \path -> Directory.createDirectoryIfMissing True (stageRoot </> path)+  forM_ (Set.toAscList (Set.fromList (mapMaybe operationDestination operations))) $ \path -> do+    stagedExists <- Directory.doesFileExist (stageRoot </> path)+    when (not stagedExists) $ do+      let projectPath = projectRoot </> path+      projectExists <- Directory.doesFileExist projectPath+      when projectExists (TIO.readFile projectPath >>= writeStageFile stageRoot path)+  pure stageRoot++writeStageFile :: FilePath -> FilePath -> Text -> IO ()+writeStageFile root path content = do+  Directory.createDirectoryIfMissing True (takeDirectory (root </> path))+  TIO.writeFile (root </> path) content++operationDestination :: Operation -> Maybe FilePath+operationDestination WriteFileOp {dest} = Just dest+operationDestination CopyFileOp {dest} = Just dest+operationDestination PatchFileOp {dest} = Just dest+operationDestination _ = Nothing++versionEvidence ::+  CandidateCatalog ->+  [AppliedComposition] ->+  [PlannedApplication] ->+  IO (Either UpdateError ([VersionChange], [UpdateWarning]))+versionEvidence catalog previousApplications plannedApplications = do+  evidence <- fmap concat $ sequence (zipWith applicationEvidence previousApplications plannedApplications)+  pure $ do+    changes <- sequence evidence+    let actualChanges = filter isActualChange changes+    traverse_ validateVersionChange actualChanges+    let unique = Map.elems (Map.fromList [(versionKey change, change) | change <- actualChanges])+        warnings =+          [ SameVersionContentChanged change.name+          | change <- unique,+            change.sameVersionContentChanged,+            isJust change.fromVersion+          ]+    Right (unique, warnings)+  where+    applicationEvidence previous planned = do+      instanceEvidence <- traverse (instanceVersionEvidence previous) planned.modulesInOrder+      targetEvidence <- targetVersionEvidence previous+      pure (targetEvidence : instanceEvidence)++    instanceVersionEvidence previous (instanceId, candidateModule, _) = do+      let prior = find (\state -> state.name == instanceId.instanceModule && state.parentVars == instanceId.instanceParentVars) previous.instances+      case prior of+        Nothing -> pure (Right (VersionChange candidateModule.name.unModuleName Nothing candidateModule.version False))+        Just old -> compareArtifact old.name.unModuleName old.moduleVersion candidateModule.version old.source CandidateModule++    targetVersionEvidence previous = case previous.target of+      AppliedModuleTarget _ -> pure (Right (VersionChange "" Nothing Nothing False))+      AppliedRecipeTarget name -> compareArtifact name.unRecipeName previous.targetVersion (candidateVersion CandidateRecipe name.unRecipeName) previous.targetSource CandidateRecipe++    candidateVersion kind name = (.version) =<< Map.lookup (kind, name) catalog.artifacts++    compareArtifact name fromVersion toVersion oldSource kind = do+      oldHashResult <- try @SomeException (hashArtifactDirectory oldSource)+      let candidateHash = (.contentHash) <$> Map.lookup (kind, name) catalog.artifacts+          changed = case (oldHashResult, candidateHash) of+            (Right oldHash, Just newHash) -> oldHash /= newHash+            _ -> True+      pure+        ( Right+            VersionChange+              { name,+                fromVersion,+                toVersion,+                sameVersionContentChanged = changed && fromVersion == toVersion+              }+        )++    versionKey change = (change.name, change.fromVersion, change.toVersion, change.sameVersionContentChanged)+    isActualChange change =+      not (T.null change.name)+        && (change.fromVersion /= change.toVersion || change.sameVersionContentChanged)++validateVersionChange :: VersionChange -> Either UpdateError ()+validateVersionChange change+  | T.null change.name = Right ()+  | otherwise = case (change.fromVersion, change.toVersion) of+      (Just fromText, Just toText) -> do+        fromVersion <- maybe (Left (CandidateVersionInvalid change.name fromText)) Right (parseVersion fromText)+        toVersion <- maybe (Left (CandidateVersionInvalid change.name toText)) Right (parseVersion toText)+        if toVersion < fromVersion+          then Left (CandidateDowngrade change.name change.fromVersion change.toVersion)+          else Right ()+      _ -> Right ()++artifactsUsedBy :: CandidateCatalog -> [PlannedApplication] -> [CandidateArtifact]+artifactsUsedBy catalog planned = Map.elems (Map.restrictKeys catalog.artifacts keys)+  where+    keys =+      Set.fromList $+        concatMap applicationKeys planned+    applicationKeys application =+      targetKey application.candidate.target+        : [(CandidateModule, modul.name.unModuleName) | (_, modul, _) <- application.modulesInOrder]+    targetKey (AppliedModuleTarget name) = (CandidateModule, name.unModuleName)+    targetKey (AppliedRecipeTarget name) = (CandidateRecipe, name.unRecipeName)++summarizeInputChanges :: [UpdateWarning] -> [PlannedApplication] -> InputChangeSummary+summarizeInputChanges seedWarnings planned =+  foldl' summarizeApplication emptySummary planned+  where+    emptySummary =+      InputChangeSummary+        { reused = 0,+          overridden = 0,+          newlyResolved = 0,+          removed = 0,+          ambiguousLegacy = [name | AmbiguousLegacyValue name <- seedWarnings]+        }+    summarizeApplication summary application =+      let prior = maybe Map.empty savedInstanceValues application.previous+          candidateValues = application.resolvedValues+          resolvedList =+            [ (instanceId, name, value)+            | (instanceId, values) <- Map.toList candidateValues,+              (name, value) <- Map.toList values+            ]+          reusedCount = length [() | (_, _, value) <- resolvedList, value.source == FromApplication]+          overriddenCount =+            length+              [ ()+              | (instanceId, name, value) <- resolvedList,+                value.source == FromCLI,+                Map.member name (Map.findWithDefault Map.empty instanceId prior)+              ]+          newCount =+            length+              [ ()+              | (instanceId, name, _) <- resolvedList,+                Map.notMember name (Map.findWithDefault Map.empty instanceId prior)+              ]+          removedCount =+            length+              [ ()+              | (instanceId, oldValues) <- Map.toList prior,+                name <- Map.keys oldValues,+                Map.notMember name (Map.findWithDefault Map.empty instanceId candidateValues)+              ]+       in summary+            { reused = summary.reused + reusedCount,+              overridden = summary.overridden + overriddenCount,+              newlyResolved = summary.newlyResolved + newCount,+              removed = summary.removed + removedCount+            }++transactionTargetPaths :: Manifest -> ReconciliationPlan -> [PlannedUpdateMigration] -> Set FilePath+transactionTargetPaths manifest reconciliation migrations =+  Map.keysSet reconciliation.files `Set.union` Set.fromList (concatMap migrationTargets migrations)+  where+    migrationTargets migration = concatMap targets migration.stagedPlan.planOps+    targets (MoveFileInst source destination _) = [source, destination]+    targets (DeleteFileInst path _) = [path]+    targets (MoveDirInst source destination) =+      concatMap (moveDirectoryTarget source destination) (Map.keys manifest.files)+    targets (DeleteDirInst path) = filter (isPathAtOrBelow path) (Map.keys manifest.files)+    targets RunCommandInst {} = []+    moveDirectoryTarget source destination path+      | isPathAtOrBelow source path = [path, replacePrefix source destination path]+      | isPathAtOrBelow destination path = [path]+      | otherwise = []++isPathAtOrBelow :: FilePath -> FilePath -> Bool+isPathAtOrBelow directory path = path == directory || (directory <> "/") `isPrefixOf` path++replacePrefix :: FilePath -> FilePath -> FilePath -> FilePath+replacePrefix source destination path+  | path == source = destination+  | otherwise = destination <> drop (length source) path++observePaths :: FilePath -> Set FilePath -> IO (Map FilePath (Maybe SHA256))+observePaths projectRoot paths =+  Map.fromList <$> traverse observe (Set.toAscList paths)+  where+    observe path = do+      let fullPath = projectRoot </> path+      exists <- Directory.doesFileExist fullPath+      hash <- if exists then Just . hashContent <$> TIO.readFile fullPath else pure Nothing+      pure (path, hash)++stalePlanPaths :: UpdatePlan -> IO (Set FilePath)+stalePlanPaths plan = do+  currentProject <-+    observePaths+      plan.snapshot.projectRoot+      (Map.keysSet plan.snapshot.observedProjectHashes)+  candidateChecks <- forM (Map.toAscList plan.snapshot.candidateHashes) $ \(path, expected) -> do+    current <- try @SomeException (hashArtifactDirectory path)+    pure $ case current of+      Right actual | actual == expected -> []+      _ -> [path]+  let projectChanges =+        Map.keysSet+          (Map.filterWithKey (\path observed -> Map.lookup path currentProject /= Just observed) plan.snapshot.observedProjectHashes)+  pure (Set.union projectChanges (Set.fromList (concat candidateChecks)))++planActualReconciliation :: UpdatePlan -> Manifest -> IO (Either UpdateError ReconciliationPlan)+planActualReconciliation plan manifest = do+  let (operations, owners, _) = combineApplicationPlans plan.plannedApplications+      selected = Set.fromList (map (.applicationId) plan.applications)+  result <-+    runEff $+      runFilesystem $+        runBaselineStore plan.snapshot.baselineDirectory $+          planReconciliation plan.snapshot.projectRoot manifest selected operations owners+  pure (first UpdateReconciliationFailed result)++-- | Reapply choices gathered after the initial read-only plan to the+-- reconciliation rebuilt after real migration commands. Exact equality is+-- still required afterward, so a command-induced classification or content+-- change remains a stale-plan failure rather than inheriting an old choice.+reapplyPlannedResolutions ::+  ReconciliationPlan ->+  ReconciliationPlan ->+  Either ReconciliationError ReconciliationPlan+reapplyPlannedResolutions planned actual =+  foldM reapplyOne actual (Map.toAscList planned.files)+  where+    reapplyOne actual (path, FileConflict _ _ _ _ _ _ (Just resolved)) =+      resolveFileConflict path resolved.choice actual+    reapplyOne actual (path, FileOrphanEdited _ _ _ _ (Just choice)) =+      resolveEditedOrphan path choice actual+    reapplyOne actual _ = Right actual++runRealMigrations :: UTCTime -> Manifest -> [PlannedUpdateMigration] -> IO (Either UpdateError Manifest)+runRealMigrations now manifest migrations =+  runEff $ runFilesystem $ runProcessIO $ go manifest migrations+  where+    go current [] = pure (Right current)+    go current (migration : rest) = do+      classified <- classifyMigration current migration.sourcePlan+      case classified of+        Left err -> pure (Left (UpdateMigrationFailed migration.moduleName err))+        Right executable -> do+          executed <- executeMigration False executable current now+          case executed of+            Left err -> pure (Left (UpdateMigrationFailed migration.moduleName err))+            Right next -> go next rest++buildFinalManifest :: UTCTime -> UpdatePlan -> Manifest -> [CommandReceipt] -> Manifest+buildFinalManifest now plan filesManifest completedReceipts =+  Manifest+    { version = currentManifestVersion,+      genAt = now,+      modules = updateAppliedModules filesManifest.modules filesManifest.applications plan.plannedApplications now,+      vars = Map.union candidateVars filesManifest.vars,+      files = filesManifest.files,+      applications = foldl' (flip replaceAppliedComposition) filesManifest.applications finalApplications,+      recipe = updatedRecipe,+      blueprint = filesManifest.blueprint,+      blueprintMigrations = filesManifest.blueprintMigrations+    }+  where+    finalApplications = map finalizeApplication plan.plannedApplications+    finalizeApplication application =+      let priorReceipts = maybe Map.empty (.commandReceipts) application.previous+          applicationPlan = planCommands plan.request.commandPolicy priorReceipts application.operations+          receipts = finalizeCommandReceipts applicationPlan completedReceipts priorReceipts+       in setCompositionState application.candidate.instances receipts application.candidate+    candidateVars =+      Map.unions+        [ Map.map (varValueToText . (.value)) values+        | application <- plan.plannedApplications,+          values <- Map.elems application.resolvedValues+        ]+    updatedRecipe =+      foldl' updateRecipe filesManifest.recipe finalApplications+    updateRecipe current application = case application.target of+      AppliedRecipeTarget name -> Just AppliedRecipe {name, recipeVersion = application.targetVersion, appliedAt = now}+      AppliedModuleTarget _ -> current++updateAppliedModules :: [AppliedModule] -> [AppliedComposition] -> [PlannedApplication] -> UTCTime -> [AppliedModule]+updateAppliedModules existing recordedApplications applications now =+  let modulesInOrder = concatMap (.modulesInOrder) applications+      candidateKeys = Set.fromList [(instanceId.instanceModule, instanceId.instanceParentVars) | (instanceId, _, _) <- modulesInOrder]+      selectedIds = Set.fromList (map (.candidate.applicationId) applications)+      priorSelectedKeys =+        Set.fromList+          [ (state.name, state.parentVars)+          | application <- applications,+            previous <- maybeToList application.previous,+            state <- previous.instances+          ]+      protectedKeys =+        Set.fromList+          [ (state.name, state.parentVars)+          | application <- recordedApplications,+            Set.notMember application.applicationId selectedIds,+            state <- application.instances+          ]+      replacedOrRemoved key =+        Set.member key candidateKeys+          || (Set.member key priorSelectedKeys && Set.notMember key protectedKeys)+      retained = filter (not . replacedOrRemoved . (\applied -> (applied.name, applied.parentVars))) existing+      updated =+        [ AppliedModule+            { name = instanceId.instanceModule,+              parentVars = instanceId.instanceParentVars,+              source = publishInstanceDirectory application instanceId,+              moduleVersion = modul.version,+              appliedAt = now,+              removal = modul.removal+            }+        | (application, instanceId, modul) <- deduplicateInstances applications+        ]+   in retained <> updated+  where+    deduplicateInstances = go Set.empty . concatMap expand+      where+        expand application =+          [ (application, instanceId, modul)+          | (instanceId, modul, _) <- application.modulesInOrder+          ]+        go _ [] = []+        go seen (entry@(_, instanceId, _) : rest)+          | Set.member key seen = go seen rest+          | otherwise = entry : go (Set.insert key seen) rest+          where+            key = (instanceId.instanceModule, instanceId.instanceParentVars)++    publishInstanceDirectory application instanceId =+      case find (\state -> state.name == instanceId.instanceModule && state.parentVars == instanceId.instanceParentVars) application.candidate.instances of+        Just state -> state.source+        Nothing -> error "candidate application lost a loaded module instance"++setCompositionState :: [AppliedInstanceState] -> Map CommandFingerprint CommandReceipt -> AppliedComposition -> AppliedComposition+setCompositionState instances receipts composition =+  AppliedComposition+    { applicationId = composition.applicationId,+      target = composition.target,+      targetSource = composition.targetSource,+      targetVersion = composition.targetVersion,+      additionalModules = composition.additionalModules,+      namespace = composition.namespace,+      context = composition.context,+      instances,+      commandReceipts = receipts,+      appliedAt = composition.appliedAt+    }++publishInstanceSource :: FilePath -> CandidateCatalog -> AppliedInstanceState -> AppliedInstanceState+publishInstanceSource installedDirectory catalog state =+  case Map.lookup (CandidateModule, state.name.unModuleName) catalog.artifacts of+    Nothing -> state+    Just artifact ->+      AppliedInstanceState+        { name = state.name,+          parentVars = state.parentVars,+          source = publishedArtifactSource installedDirectory artifact,+          moduleVersion = state.moduleVersion,+          resolvedVars = state.resolvedVars+        }++publishedArtifactSource :: FilePath -> CandidateArtifact -> FilePath+publishedArtifactSource installedDirectory artifact =+  if isJust artifact.sourceUrl+    then installedDirectory </> T.unpack artifact.name+    else artifact.originalDirectory++publishCandidates :: [CandidateArtifact] -> IO (Either UpdateError ())+publishCandidates artifacts = do+  result <- try @SomeException $+    forM_ artifacts $ \artifact -> case artifact.sourceUrl of+      Nothing -> pure ()+      Just sourceUrl ->+        installModuleDir+          artifact.originalDirectory+          (T.unpack artifact.name)+          sourceUrl+          artifact.repoName+          artifact.version+          artifact.tags+  pure $ first (UpdateCachePublicationFailed . T.pack . displayException) result++setCommitMarkers :: UpdateTransaction -> Manifest -> IO (Either UpdateError ())+setCommitMarkers transaction manifest = do+  core <- setUpdateTransactionExpectedManifest transaction manifest+  case core of+    Left err -> pure (Left (UpdateTransactionFailed err))+    Right () -> setServiceExpectedManifest transaction manifest++writeManifestIO :: FilePath -> Manifest -> IO (Either UpdateError ())+writeManifestIO path manifest = do+  result <- try @SomeException $ runEff $ runFilesystem $ runManifestStore path $ writeManifest manifest+  pure $ first (UpdateManifestWriteFailed . T.pack . displayException) result++readManifestIO :: FilePath -> IO (Either UpdateError Manifest)+readManifestIO path = do+  result <- try @SomeException $ runEff $ runFilesystem $ runManifestStore path readManifest+  pure $ case result of+    Left err -> Left (UpdateManifestUnreadable path (T.pack (displayException err)))+    Right (Left err) -> Left (UpdateManifestUnreadable path err)+    Right (Right Nothing) -> Left (UpdateManifestMissing path)+    Right (Right (Just manifest)) -> Right manifest++abortUpdate :: UpdateTransaction -> UpdateError -> IO (Either UpdateError a)+abortUpdate transaction original = do+  serviceRestore <- restoreServiceBackups transaction+  coreRestore <- rollbackUpdateTransaction transaction+  pure $ case (serviceRestore, coreRestore) of+    (Left restoreError, _) -> Left restoreError+    (_, Left restoreError) -> Left (UpdateTransactionFailed restoreError)+    _ -> Left original++finishCommitted ::+  UpdateTransaction ->+  UpdatePlan ->+  Manifest ->+  [CommandReceipt] ->+  IO (Either UpdateError UpdateResult)+finishCommitted transaction plan manifest completedReceipts = do+  completion <- completeUpdateTransaction transaction+  pruneResult <-+    try @SomeException $+      runEff $+        runFilesystem $+          runBaselineStore plan.snapshot.baselineDirectory $+            pruneBaselines (manifestBaselineRefs manifest)+  let cleanupWarnings = case completion of+        Left err -> [RecoveryCleanupDeferred (T.pack (show err))]+        Right () -> []+      pruneWarnings = case pruneResult of+        Left err -> [BaselinePruneFailed (T.pack (displayException err))]+        Right _ -> []+      summary = summarizeCommandPlan plan.commandPlan+      originalBaselineRefs = manifestBaselineRefs plan.snapshot.originalManifest+      finalBaselineRefs = manifestBaselineRefs manifest+      changedBaselineRefs =+        (originalBaselineRefs Set.\\ finalBaselineRefs)+          `Set.union` (finalBaselineRefs Set.\\ originalBaselineRefs)+      baselinePaths =+        Set.map+          (\ref -> ".seihou" </> "baselines" </> T.unpack ref.unBaselineRef.unSHA256)+          changedBaselineRefs+      touched =+        plan.snapshot.transactionTargets+          `Set.union` baselinePaths+          `Set.union` Set.singleton (".seihou" </> "manifest.json")+  pure+    ( Right+        UpdateResult+          { updatedApplications = map (.applicationId) plan.applications,+            manifest,+            versions = plan.versionChanges,+            fileSummary = reconciliationSummary plan.reconciliation,+            commandSummary =+              CommandSummary+                { executed = length completedReceipts,+                  skippedUnchanged = summary.skippedUnchanged,+                  skippedDisabled = summary.skippedDisabled+                },+            touchedPaths = touched,+            warnings = plan.warnings <> cleanupWarnings <> pruneWarnings+          }+    )++recoverAtEntry :: FilePath -> IO (Either UpdateError ())+recoverAtEntry projectRoot = do+  serviceResults <- recoverServiceBackups projectRoot+  case [err | Left err <- serviceResults] of+    firstFailure : _ -> pure (Left firstFailure)+    [] -> do+      coreResults <- recoverIncompleteTransactions projectRoot+      let failures = [err | Left err <- coreResults]+      pure $ if null failures then Right () else Left (UpdateRecoveryFailed failures)++standardInstalledDirectory :: IO FilePath+standardInstalledDirectory = do+  searchPaths <- defaultSearchPaths+  pure (last searchPaths)++dryRunResult :: UpdatePlan -> UpdateResult+dryRunResult plan =+  (noOpResult plan)+    { versions = plan.versionChanges,+      fileSummary = reconciliationSummary plan.reconciliation,+      commandSummary = commandSummaryForPlan plan.commandPlan,+      warnings = plan.warnings+    }++noOpResult :: UpdatePlan -> UpdateResult+noOpResult plan =+  UpdateResult+    { updatedApplications = [],+      manifest = plan.snapshot.originalManifest,+      versions = [],+      fileSummary = reconciliationSummary plan.reconciliation,+      commandSummary = CommandSummary 0 0 0,+      touchedPaths = Set.empty,+      warnings = plan.warnings+    }++commandSummaryForPlan :: CommandPlan -> CommandSummary+commandSummaryForPlan commandPlan =+  let summary = summarizeCommandPlan commandPlan+   in CommandSummary summary.willRun summary.skippedUnchanged summary.skippedDisabled++isUpdateNoOp :: UpdatePlan -> Bool+isUpdateNoOp plan =+  null plan.versionChanges+    && null plan.migrations+    && plan.inputChanges.overridden == 0+    && plan.inputChanges.newlyResolved == 0+    && plan.inputChanges.removed == 0+    && (summarizeCommandPlan plan.commandPlan).willRun == 0+    && all unchangedFile (Map.elems plan.reconciliation.files)+    && and (zipWith sameApplication (mapMaybe (.previous) plan.plannedApplications) plan.applications)+  where+    unchangedFile FileUnchanged {} = True+    unchangedFile _ = False+    sameApplication previous candidate =+      previous.target == candidate.target+        && previous.targetSource == candidate.targetSource+        && previous.targetVersion == candidate.targetVersion+        && previous.additionalModules == candidate.additionalModules+        && previous.namespace == candidate.namespace+        && previous.context == candidate.context+        && previous.instances == candidate.instances+        && previous.commandReceipts == candidate.commandReceipts++isStructuredNoOp :: UpdatePlan -> Bool+isStructuredNoOp = isUpdateNoOp++varValueToText :: VarValue -> Text+varValueToText (VText value) = value+varValueToText (VBool True) = "true"+varValueToText (VBool False) = "false"+varValueToText (VInt value) = T.pack (show value)+varValueToText (VList values) = T.intercalate "," (map varValueToText values)
+ src/Seihou/CLI/Update/Interaction.hs view
@@ -0,0 +1,170 @@+module Seihou.CLI.Update.Interaction+  ( InteractionMode (..),+    InteractionError (..),+    ResolutionDecision (..),+    applyResolutionDecisions,+    forceResolveUpdatePlan,+    resolveInteractively,+  )+where++import Control.Exception (IOException, try)+import Control.Monad (foldM)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Seihou.CLI.Update.Types (UpdatePlan (..))+import Seihou.Engine.Reconcile+  ( FileConflictChoice (..),+    FileReconciliation (..),+    OrphanChoice (..),+    ReconciliationError,+    ReconciliationPlan (..),+    ReconciliationReason (..),+    resolveEditedOrphan,+    resolveFileConflict,+    unresolvedPaths,+  )+import Seihou.Prelude+import System.IO (hFlush, isEOF, stderr, stdin)++data InteractionMode = Interactive | NonInteractive+  deriving stock (Eq, Show)++data InteractionError+  = InteractionRequired (Set FilePath)+  | InteractionAborted FilePath+  | InteractionResolutionFailed ReconciliationError+  | InteractionInputFailed Text+  deriving stock (Eq, Show)++data ResolutionDecision+  = ResolveFile FilePath FileConflictChoice+  | ResolveOrphan FilePath OrphanChoice+  deriving stock (Eq, Show)++applyResolutionDecisions ::+  [ResolutionDecision] ->+  UpdatePlan ->+  Either InteractionError UpdatePlan+applyResolutionDecisions decisions plan = do+  reconciliation <- foldM applyOne plan.reconciliation decisions+  pure plan {reconciliation}+  where+    applyOne current (ResolveFile path choice) =+      first InteractionResolutionFailed (resolveFileConflict path choice current)+    applyOne current (ResolveOrphan path choice) =+      first InteractionResolutionFailed (resolveEditedOrphan path choice current)++forceResolveUpdatePlan :: UpdatePlan -> Either InteractionError UpdatePlan+forceResolveUpdatePlan plan = applyResolutionDecisions decisions plan+  where+    decisions = concatMap forceOne (Map.toAscList plan.reconciliation.files)+    forceOne (path, FileConflict _ _ _ reason _ _ Nothing) = case reason of+      MergeDriverUnavailable _ -> []+      _ -> [ResolveFile path AcceptGenerated]+    forceOne (path, FileOrphanEdited _ _ _ _ Nothing) =+      [ResolveOrphan path RetainTrackedOrphan]+    forceOne _ = []++resolveInteractively ::+  InteractionMode ->+  UpdatePlan ->+  IO (Either InteractionError UpdatePlan)+resolveInteractively mode plan+  | Set.null remaining = pure (Right plan)+  | mode == NonInteractive = pure (Left (InteractionRequired remaining))+  | otherwise = go plan (Map.toAscList plan.reconciliation.files)+  where+    remaining = unresolvedPaths plan.reconciliation+    go current [] = pure (Right current)+    go current ((path, reconciliation) : rest) = case reconciliation of+      FileConflict _ currentText markers reason _ _ Nothing -> do+        decision <- promptConflict path currentText markers reason+        case decision of+          Left err -> pure (Left err)+          Right choice -> case applyResolutionDecisions [ResolveFile path choice] current of+            Left err -> pure (Left err)+            Right updated -> go updated rest+      FileOrphanEdited _ _ content _ Nothing -> do+        decision <- promptOrphan path content+        case decision of+          Left err -> pure (Left err)+          Right choice -> case applyResolutionDecisions [ResolveOrphan path choice] current of+            Left err -> pure (Left err)+            Right updated -> go updated rest+      _ -> go current rest++promptConflict ::+  FilePath ->+  Text ->+  Text ->+  ReconciliationReason ->+  IO (Either InteractionError FileConflictChoice)+promptConflict path current markers reason = do+  TIO.hPutStrLn stderr ("Conflict: " <> T.pack path <> " (" <> T.pack (show reason) <> ")")+  TIO.hPutStrLn stderr "  Labels: baseline = previous generated, current = project, generated = candidate"+  TIO.hPutStrLn stderr "  Diff3 preview:"+  TIO.hPutStrLn stderr (indentPreview (if T.null markers then current else markers))+  promptChoice path "Choose [g]enerated, [k]eep current, [m]arkers, [a]bort: " parse+  where+    parse "g" = Just AcceptGenerated+    parse "generated" = Just AcceptGenerated+    parse "k" = Just KeepCurrent+    parse "keep" = Just KeepCurrent+    parse "m" = Just WriteConflictMarkers+    parse "markers" = Just WriteConflictMarkers+    parse "a" = Nothing+    parse "abort" = Nothing+    parse _ = Nothing++promptOrphan :: FilePath -> Text -> IO (Either InteractionError OrphanChoice)+promptOrphan path content = do+  TIO.hPutStrLn stderr ("Edited orphan: " <> T.pack path)+  TIO.hPutStrLn stderr (indentPreview content)+  promptChoice path "Choose [d]elete, [r]etain tracked, [u]nmanage and keep, [a]bort: " parse+  where+    parse "d" = Just DeleteEditedOrphan+    parse "delete" = Just DeleteEditedOrphan+    parse "r" = Just RetainTrackedOrphan+    parse "retain" = Just RetainTrackedOrphan+    parse "u" = Just DetachAndKeepOrphan+    parse "unmanage" = Just DetachAndKeepOrphan+    parse "a" = Nothing+    parse "abort" = Nothing+    parse _ = Nothing++promptChoice ::+  FilePath ->+  Text ->+  (Text -> Maybe a) ->+  IO (Either InteractionError a)+promptChoice path prompt parse = loop+  where+    loop = do+      TIO.hPutStr stderr prompt+      hFlush stderr+      eofResult <- try @IOException isEOF+      case eofResult of+        Left err -> pure (Left (InteractionInputFailed (T.pack (show err))))+        Right True -> pure (Left (InteractionAborted path))+        Right False -> do+          inputResult <- try @IOException TIO.getLine+          case inputResult of+            Left err -> pure (Left (InteractionInputFailed (T.pack (show err))))+            Right input ->+              let normalized = T.toLower (T.strip input)+               in if normalized `elem` ["a", "abort"]+                    then pure (Left (InteractionAborted path))+                    else case parse normalized of+                      Just choice -> pure (Right choice)+                      _ -> do+                        TIO.hPutStrLn stderr "Invalid choice; enter one of the displayed letters or words."+                        loop++indentPreview :: Text -> Text+indentPreview content =+  let shownLines = take 80 (T.lines content)+      suffix = if length (T.lines content) > 80 then ["  ... preview truncated ..."] else []+   in T.unlines (map ("  " <>) shownLines <> suffix)
+ src/Seihou/CLI/Update/Migrations.hs view
@@ -0,0 +1,233 @@+module Seihou.CLI.Update.Migrations+  ( StagedMigrations (..),+    planAndStageMigrations,+    migrationTouchedPaths,+    migrationTouchesDirectories,+  )+where++import Control.Monad (guard)+import Data.List (find)+import Data.Map.Strict qualified as Map+import Data.Maybe (mapMaybe)+import Data.Set qualified as Set+import Data.Text.IO qualified as TIO+import Effectful (runPureEff)+import Seihou.CLI.Update.Types+import Seihou.Composition.Instance (ModuleInstance (..))+import Seihou.Core.Migration (Migration (..), MigrationOp (..), MigrationPlan (..), planMigrationChain)+import Seihou.Core.Types+import Seihou.Core.Version (parseVersion)+import Seihou.Effect.FilesystemPure (PureFS (..), runFilesystemPure)+import Seihou.Effect.ProcessPure (ProcessMock (..), runProcessPure)+import Seihou.Engine.Migrate+  ( ExecutedMigrationPlan (..),+    MigrationOpInstance (..),+    classifyMigration,+    executeMigration,+  )+import Seihou.Prelude+import System.Directory qualified as Directory+import System.Exit (ExitCode (..))+import System.FilePath (takeDirectory)++data StagedMigrations = StagedMigrations+  { plans :: [PlannedUpdateMigration],+    manifest :: Manifest,+    filesystem :: PureFS,+    warnings :: [UpdateWarning]+  }+  deriving stock (Eq, Show)++data Transition = Transition+  { moduleName :: ModuleName,+    originUrl :: Maybe Text,+    fromVersion :: Text,+    toVersion :: Text,+    candidateModule :: Module,+    sourceDirectory :: FilePath+  }++-- | Deduplicate equal module transitions and simulate them against a complete+-- snapshot of tracked project text. Shell commands are mocked as successful+-- and retained as explicit warnings for the real apply/revalidation phase.+planAndStageMigrations ::+  FilePath ->+  Manifest ->+  CandidateCatalog ->+  [(Maybe AppliedComposition, [(ModuleInstance, Module, FilePath)])] ->+  IO (Either UpdateError StagedMigrations)+planAndStageMigrations projectRoot manifest catalog applications = do+  initialFilesystem <- snapshotTrackedFiles projectRoot manifest+  pure $ do+    transitions <- collectTransitions catalog applications+    planned <- traverse planTransition (deduplicateTransitions transitions)+    let processMocks = concatMap commandMocks planned+        ((stageResult, finalFilesystem)) =+          runPureEff $+            runFilesystemPure initialFilesystem $+              runProcessPure processMocks $+                stageAll manifest [] planned+    (finalManifest, stagedPlans) <- stageResult+    let warnings =+          [ MigrationCommandNotSimulated plannedMigration.moduleName command+          | plannedMigration <- stagedPlans,+            RunCommandInst command _ <- plannedMigration.stagedPlan.planOps+          ]+    Right+      StagedMigrations+        { plans = stagedPlans,+          manifest = finalManifest,+          filesystem = finalFilesystem,+          warnings+        }++stageAll manifest completed [] = pure (Right (manifest, reverse completed))+stageAll manifest completed ((transition, sourcePlan) : rest) = do+  classified <- classifyMigration manifest sourcePlan+  case classified of+    Left err -> pure (Left (UpdateMigrationStageFailed transition.moduleName err))+    Right stagedPlan -> do+      executed <- executeMigration False stagedPlan manifest manifest.genAt+      case executed of+        Left err -> pure (Left (UpdateMigrationStageFailed transition.moduleName err))+        Right nextManifest ->+          let planned =+                PlannedUpdateMigration+                  { moduleName = transition.moduleName,+                    sourceDirectory = transition.sourceDirectory,+                    sourcePlan,+                    stagedPlan,+                    containsCommands = any isCommand stagedPlan.planOps+                  }+           in stageAll nextManifest (planned : completed) rest+  where+    isCommand RunCommandInst {} = True+    isCommand _ = False++collectTransitions ::+  CandidateCatalog ->+  [(Maybe AppliedComposition, [(ModuleInstance, Module, FilePath)])] ->+  Either UpdateError [Transition]+collectTransitions catalog applications = do+  let raw = concatMap applicationTransitions applications+      priorByModule =+        Map.fromListWith+          Set.union+          [ ((transition.moduleName, transition.originUrl), Set.singleton transition.fromVersion)+          | transition <- raw+          ]+  case [ (name, Set.toAscList versions)+       | ((name, _), versions) <- Map.toAscList priorByModule,+         Set.size versions > 1+       ] of+    (name, versions) : _ -> Left (UpdateConflictingPriorVersions name versions)+    [] -> Right raw+  where+    applicationTransitions (Nothing, _) = []+    applicationTransitions (Just previous, candidates) =+      mapMaybe (transitionFor previous) candidates++    transitionFor previous (instanceId, candidateModule, sourceDirectory) = do+      prior <- find (matches instanceId) previous.instances+      fromVersion <- prior.moduleVersion+      toVersion <- candidateModule.version+      guard (fromVersion /= toVersion)+      let artifact = Map.lookup (CandidateModule, candidateModule.name.unModuleName) catalog.artifacts+      pure+        Transition+          { moduleName = candidateModule.name,+            originUrl = artifact >>= (.sourceUrl),+            fromVersion,+            toVersion,+            candidateModule,+            sourceDirectory+          }++    matches instanceId state =+      state.name == instanceId.instanceModule+        && state.parentVars == instanceId.instanceParentVars++deduplicateTransitions :: [Transition] -> [Transition]+deduplicateTransitions = go Set.empty+  where+    go _ [] = []+    go seen (transition : rest)+      | Set.member (transitionKey transition) seen = go seen rest+      | otherwise = transition : go (Set.insert (transitionKey transition) seen) rest++    transitionKey transition =+      ( transition.moduleName,+        transition.originUrl,+        transition.fromVersion,+        transition.toVersion+      )++planTransition :: Transition -> Either UpdateError (Transition, MigrationPlan)+planTransition transition = do+  fromVersion <-+    maybe+      (Left (CandidateVersionInvalid transition.moduleName.unModuleName transition.fromVersion))+      Right+      (parseVersion transition.fromVersion)+  toVersion <-+    maybe+      (Left (CandidateVersionInvalid transition.moduleName.unModuleName transition.toVersion))+      Right+      (parseVersion transition.toVersion)+  planned <-+    first+      (UpdateMigrationPlanFailed transition.moduleName)+      (planMigrationChain transition.moduleName.unModuleName transition.candidateModule.migrations fromVersion toVersion)+  case planned of+    Nothing -> error "planTransition received unequal versions but no migration plan"+    Just sourcePlan -> Right (transition, sourcePlan)++commandMocks :: (Transition, MigrationPlan) -> [ProcessMock]+commandMocks (_, sourcePlan) =+  [ ProcessMock+      { mockCommand = "/bin/sh",+        mockArgs = ["-c", command],+        mockResult = (ExitSuccess, "", "")+      }+  | migration <- sourcePlan.planSteps,+    RunCommand command _ <- migration.ops+  ]++snapshotTrackedFiles :: FilePath -> Manifest -> IO PureFS+snapshotTrackedFiles projectRoot manifest = do+  files <- fmap Map.fromList . fmap concat $ traverse readTracked (Map.keys manifest.files)+  let directories =+        Set.fromList+          [ directory+          | path <- Map.keys files,+            directory <- parents path+          ]+  pure PureFS {files, dirs = directories}+  where+    readTracked path = do+      let fullPath = projectRoot </> path+      exists <- Directory.doesFileExist fullPath+      if exists+        then do+          content <- TIO.readFile fullPath+          pure [(path, content)]+        else pure []++    parents path = takeWhile (\directory -> directory /= "." && directory /= "") (iterate takeDirectory (takeDirectory path))++migrationTouchedPaths :: [PlannedUpdateMigration] -> Set FilePath+migrationTouchedPaths = Set.fromList . concatMap (concatMap touched . (.stagedPlan.planOps))+  where+    touched (MoveFileInst source destination _) = [source, destination]+    touched (MoveDirInst source destination) = [source, destination]+    touched (DeleteFileInst path _) = [path]+    touched (DeleteDirInst path) = [path]+    touched RunCommandInst {} = []++migrationTouchesDirectories :: PlannedUpdateMigration -> Bool+migrationTouchesDirectories migration = any touchesDirectory migration.stagedPlan.planOps+  where+    touchesDirectory MoveDirInst {} = True+    touchesDirectory DeleteDirInst {} = True+    touchesDirectory _ = False
+ src/Seihou/CLI/Update/Recovery.hs view
@@ -0,0 +1,278 @@+module Seihou.CLI.Update.Recovery+  ( prepareServiceBackups,+    setServiceExpectedManifest,+    restoreServiceBackups,+    recoverServiceBackups,+  )+where++import Control.Exception (SomeException, displayException, try)+import Control.Monad (forM, forM_, unless, when)+import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?), (.=))+import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy qualified as LBS+import Data.List (isPrefixOf, sortOn)+import Data.Maybe (catMaybes, isJust)+import Data.Ord (Down (..))+import Data.Set qualified as Set+import Data.Text qualified as T+import Seihou.CLI.Update.Types+import Seihou.Core.Path (validateProjectRelativePath)+import Seihou.Core.Types+import Seihou.Engine.Migrate (ExecutedMigrationPlan (..), MigrationOpInstance (..))+import Seihou.Engine.UpdateTransaction (UpdateTransaction (..))+import Seihou.Manifest.Types (manifestFromJSON)+import Seihou.Prelude+import System.Directory qualified as Directory+import System.FilePath (splitDirectories, takeDirectory, takeFileName)++data BackupScope = ProjectDirectory | InstalledArtifact+  deriving stock (Eq, Show)++data ServiceBackup = ServiceBackup+  { scope :: BackupScope,+    target :: FilePath,+    backupName :: Maybe FilePath+  }+  deriving stock (Eq, Show)++data ServiceJournal = ServiceJournal+  { version :: Int,+    installedRoot :: FilePath,+    entries :: [ServiceBackup],+    expectedManifest :: Maybe Manifest+  }+  deriving stock (Eq, Show)++instance ToJSON BackupScope where+  toJSON ProjectDirectory = Aeson.String "project-directory"+  toJSON InstalledArtifact = Aeson.String "installed-artifact"++instance FromJSON BackupScope where+  parseJSON = Aeson.withText "BackupScope" $ \case+    "project-directory" -> pure ProjectDirectory+    "installed-artifact" -> pure InstalledArtifact+    other -> fail ("unknown backup scope: " <> T.unpack other)++instance ToJSON ServiceBackup where+  toJSON entry =+    Aeson.object+      [ "scope" .= entry.scope,+        "target" .= entry.target,+        "backup" .= entry.backupName+      ]++instance FromJSON ServiceBackup where+  parseJSON = Aeson.withObject "ServiceBackup" $ \object ->+    ServiceBackup <$> object .: "scope" <*> object .: "target" <*> object .:? "backup"++instance ToJSON ServiceJournal where+  toJSON journal =+    Aeson.object+      [ "version" .= journal.version,+        "installedRoot" .= journal.installedRoot,+        "entries" .= journal.entries,+        "expectedManifest" .= journal.expectedManifest+      ]++instance FromJSON ServiceJournal where+  parseJSON = Aeson.withObject "ServiceJournal" $ \object -> do+    version <- object .: "version"+    unless (version == (1 :: Int)) (fail "unsupported service journal version")+    ServiceJournal+      <$> pure version+      <*> object .: "installedRoot"+      <*> object .: "entries"+      <*> object .:? "expectedManifest"++-- | Persist byte-for-byte backups for whole migration directories and shared+-- cache destinations inside EP-66's transaction directory. The companion+-- recovery pass runs before EP-66 recovery and uses the same manifest commit+-- boundary.+prepareServiceBackups ::+  UpdateTransaction ->+  FilePath ->+  [PlannedUpdateMigration] ->+  [CandidateArtifact] ->+  IO (Either UpdateError ())+prepareServiceBackups transaction installedRoot migrations artifacts = do+  let projectDirectories = normalizeDirectories (concatMap migrationDirectories migrations)+      installedNames =+        Set.toAscList . Set.fromList $+          [ T.unpack artifact.name+          | artifact <- artifacts,+            isJust artifact.sourceUrl+          ]+      requests =+        map (ProjectDirectory,) projectDirectories+          <> map (InstalledArtifact,) installedNames+      backupRoot = transaction.transactionDirectory </> "service-backups"+  result <- try @SomeException $ do+    Directory.createDirectoryIfMissing True backupRoot+    entries <- forM (zip [0 :: Int ..] requests) $ \(index, (scope, target)) -> do+      fullTarget <- resolveTarget transaction.projectRoot installedRoot scope target+      exists <- Directory.doesDirectoryExist fullTarget+      if exists+        then do+          let backupName = show index+              backupPath = backupRoot </> backupName+          copyDirectoryBytes fullTarget backupPath+          pure ServiceBackup {scope, target, backupName = Just backupName}+        else pure ServiceBackup {scope, target, backupName = Nothing}+    writeServiceJournal+      transaction.transactionDirectory+      ServiceJournal+        { version = 1,+          installedRoot,+          entries,+          expectedManifest = Nothing+        }+  pure $ first (UpdateCachePublicationFailed . T.pack . displayException) result++setServiceExpectedManifest :: UpdateTransaction -> Manifest -> IO (Either UpdateError ())+setServiceExpectedManifest transaction expected = do+  current <- readServiceJournal transaction.transactionDirectory+  case current of+    Left err -> pure (Left err)+    Right Nothing -> pure (Right ())+    Right (Just journal) -> do+      result <-+        try @SomeException $+          writeServiceJournal+            transaction.transactionDirectory+            journal {expectedManifest = Just expected}+      pure $ first (UpdateManifestWriteFailed . T.pack . displayException) result++restoreServiceBackups :: UpdateTransaction -> IO (Either UpdateError ())+restoreServiceBackups transaction = do+  current <- readServiceJournal transaction.transactionDirectory+  case current of+    Left err -> pure (Left err)+    Right Nothing -> pure (Right ())+    Right (Just journal) -> restoreJournal transaction.projectRoot transaction.transactionDirectory journal++-- | Restore or accept every service journal before the core transaction+-- recovery pass. A durable matching manifest means cache/directory publication+-- committed; every other state restores the byte backups.+recoverServiceBackups :: FilePath -> IO [Either UpdateError ()]+recoverServiceBackups projectRoot = do+  let transactionsRoot = projectRoot </> ".seihou" </> "transactions"+  exists <- Directory.doesDirectoryExist transactionsRoot+  if not exists+    then pure []+    else do+      names <- Directory.listDirectory transactionsRoot+      fmap catMaybes . forM names $ \name -> do+        let transactionDirectory = transactionsRoot </> name+        isDirectory <- Directory.doesDirectoryExist transactionDirectory+        if not isDirectory+          then pure Nothing+          else do+            current <- readServiceJournal transactionDirectory+            case current of+              Left err -> pure (Just (Left err))+              Right Nothing -> pure Nothing+              Right (Just journal) -> do+                committed <- manifestMatches projectRoot journal.expectedManifest+                if committed+                  then pure (Just (Right ()))+                  else Just <$> restoreJournal projectRoot transactionDirectory journal++restoreJournal :: FilePath -> FilePath -> ServiceJournal -> IO (Either UpdateError ())+restoreJournal projectRoot transactionDirectory journal = do+  result <- try @SomeException $+    forM_ (sortOn (Down . pathDepth . (.target)) journal.entries) $ \entry -> do+      fullTarget <- resolveTarget projectRoot journal.installedRoot entry.scope entry.target+      targetExists <- Directory.doesPathExist fullTarget+      when targetExists (Directory.removePathForcibly fullTarget)+      case entry.backupName of+        Nothing -> pure ()+        Just backupName -> do+          validateBackupName backupName+          copyDirectoryBytes (transactionDirectory </> "service-backups" </> backupName) fullTarget+  pure $ first (UpdateCachePublicationFailed . T.pack . displayException) result++resolveTarget :: FilePath -> FilePath -> BackupScope -> FilePath -> IO FilePath+resolveTarget projectRoot _ ProjectDirectory target =+  case validateProjectRelativePath (T.pack target) of+    Left reason -> ioError (userError ("unsafe project backup path: " <> T.unpack reason))+    Right safe+      | safe == "." || targetsControlPath safe -> ioError (userError "unsafe project backup target")+      | otherwise -> pure (projectRoot </> safe)+resolveTarget _ installedRoot InstalledArtifact target+  | takeFileName target == target && target /= "." && target /= ".." = pure (installedRoot </> target)+  | otherwise = ioError (userError "unsafe installed artifact backup target")++validateBackupName :: FilePath -> IO ()+validateBackupName name+  | takeFileName name == name && name /= "." && name /= ".." = pure ()+  | otherwise = ioError (userError "unsafe service backup name")++targetsControlPath :: FilePath -> Bool+targetsControlPath path = case splitDirectories path of+  first : _ -> first == ".seihou" || first == ".git"+  [] -> False++migrationDirectories :: PlannedUpdateMigration -> [FilePath]+migrationDirectories migration = concatMap directories migration.stagedPlan.planOps+  where+    directories (MoveDirInst source destination) = [source, destination]+    directories (DeleteDirInst path) = [path]+    directories _ = []++normalizeDirectories :: [FilePath] -> [FilePath]+normalizeDirectories paths = filter notCovered sorted+  where+    sorted = sortOn pathDepth (Set.toAscList (Set.fromList paths))+    notCovered path = not (any (isParentOf path) sorted)+    isParentOf child parent =+      parent /= child+        && splitDirectories parent `isPrefixOf` splitDirectories child++pathDepth :: FilePath -> Int+pathDepth = length . splitDirectories++copyDirectoryBytes :: FilePath -> FilePath -> IO ()+copyDirectoryBytes source destination = do+  Directory.createDirectoryIfMissing True destination+  names <- Directory.listDirectory source+  forM_ names $ \name -> do+    let sourcePath = source </> name+        destinationPath = destination </> name+    isDirectory <- Directory.doesDirectoryExist sourcePath+    if isDirectory+      then copyDirectoryBytes sourcePath destinationPath+      else Directory.copyFile sourcePath destinationPath++writeServiceJournal :: FilePath -> ServiceJournal -> IO ()+writeServiceJournal transactionDirectory journal = do+  let path = serviceJournalPath transactionDirectory+      temporaryPath = path <> ".tmp"+  LBS.writeFile temporaryPath (Aeson.encode journal)+  Directory.renamePath temporaryPath path++readServiceJournal :: FilePath -> IO (Either UpdateError (Maybe ServiceJournal))+readServiceJournal transactionDirectory = do+  let path = serviceJournalPath transactionDirectory+  exists <- Directory.doesFileExist path+  if not exists+    then pure (Right Nothing)+    else do+      result <- try @SomeException (LBS.readFile path)+      pure $ case result of+        Left err -> Left (UpdateCachePublicationFailed (T.pack (displayException err)))+        Right bytes -> first (UpdateCachePublicationFailed . T.pack) (Just <$> Aeson.eitherDecode bytes)++serviceJournalPath :: FilePath -> FilePath+serviceJournalPath transactionDirectory = transactionDirectory </> "service-journal.json"++manifestMatches :: FilePath -> Maybe Manifest -> IO Bool+manifestMatches _ Nothing = pure False+manifestMatches projectRoot (Just expected) = do+  let path = projectRoot </> ".seihou" </> "manifest.json"+  exists <- Directory.doesFileExist path+  if not exists+    then pure False+    else do+      bytes <- LBS.readFile path+      pure (manifestFromJSON bytes == Right expected)
+ src/Seihou/CLI/Update/Render.hs view
@@ -0,0 +1,396 @@+module Seihou.CLI.Update.Render+  ( UpdateOutput (..),+    UpdatePlanView,+    UpdateResultView,+    UpdateErrorView,+    planOutput,+    resultOutput,+    errorOutput,+    renderUpdateHuman,+    encodeUpdateOutput,+  )+where++import Data.Aeson (Value, encode, object, (.=))+import Data.ByteString.Lazy (ByteString)+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe)+import Data.Set qualified as Set+import Data.Text qualified as T+import Seihou.CLI.CommandExecution+  ( CommandDisposition (..),+    CommandPlan (..),+    CommandPlanSummary (..),+    PlannedCommand (..),+    summarizeCommandPlan,+  )+import Seihou.CLI.Update.Types+import Seihou.Core.Migration (MigrationPlan (..))+import Seihou.Core.Types+  ( ApplicationId (..),+    AppliedComposition (..),+    AppliedTarget (..),+    BaselineRef (..),+    CommandFingerprint (..),+    ModuleName (..),+    Operation (..),+    RecipeName (..),+    SHA256 (..),+    VarName (..),+  )+import Seihou.Engine.Reconcile+  ( DesiredFile (..),+    FileConflictChoice (..),+    FileReconciliation (..),+    OrphanChoice (..),+    ReconciliationPlan (..),+    ReconciliationSummary (..),+    ResolvedFileConflict (..),+    reconciliationSummary,+  )+import Seihou.Prelude++newtype UpdatePlanView = UpdatePlanView UpdatePlan++newtype UpdateResultView = UpdateResultView UpdateResult++newtype UpdateErrorView = UpdateErrorView UpdateError++data UpdateOutput+  = UpdatePlanOutput UpdatePlanView+  | UpdateAppliedOutput UpdateResultView+  | UpdateFailedOutput UpdateErrorView++planOutput :: UpdatePlan -> UpdateOutput+planOutput = UpdatePlanOutput . UpdatePlanView++resultOutput :: UpdateResult -> UpdateOutput+resultOutput = UpdateAppliedOutput . UpdateResultView++errorOutput :: UpdateError -> UpdateOutput+errorOutput = UpdateFailedOutput . UpdateErrorView++renderUpdateHuman :: Bool -> UpdateOutput -> Text+renderUpdateHuman _ (UpdatePlanOutput (UpdatePlanView plan)) =+  T.unlines $+    versionLines plan+      <> [ renderInputs plan.inputChanges,+           "Migrations:  " <> count (length plan.migrations) <> migrationCaveat plan,+           renderFiles (reconciliationSummary plan.reconciliation),+           renderCommands (summarizeCommandPlan plan.commandPlan)+         ]+      <> conflictLines plan.reconciliation+      <> warningLines plan.warnings+renderUpdateHuman _ (UpdateAppliedOutput (UpdateResultView result)) =+  T.unlines $+    [ "Updated " <> count (length result.updatedApplications) <> " application(s).",+      renderFiles result.fileSummary,+      "Commands:    "+        <> count result.commandSummary.executed+        <> " executed; "+        <> count result.commandSummary.skippedUnchanged+        <> " unchanged skipped; "+        <> count result.commandSummary.skippedDisabled+        <> " disabled"+    ]+      <> warningLines result.warnings+renderUpdateHuman _ (UpdateFailedOutput (UpdateErrorView err)) =+  "Update failed [" <> errorCode err <> "]: " <> errorMessage err <> "\n"++encodeUpdateOutput :: UpdateOutput -> ByteString+encodeUpdateOutput = encode . outputValue++outputValue :: UpdateOutput -> Value+outputValue (UpdatePlanOutput (UpdatePlanView plan)) =+  object+    [ "schemaVersion" .= (1 :: Int),+      "outcome" .= ("plan" :: Text),+      "alreadyUpToDate" .= planLooksUnchanged plan,+      "applications" .= map applicationIdText plan.applications,+      "versions" .= map versionValue plan.versionChanges,+      "inputs" .= inputValue plan.inputChanges,+      "migrations" .= map migrationValue plan.migrations,+      "files" .= map fileValue (Map.toAscList plan.reconciliation.files),+      "commands" .= map commandValue plan.commandPlan.commands,+      "warnings" .= map warningText plan.warnings+    ]+outputValue (UpdateAppliedOutput (UpdateResultView result)) =+  object+    [ "schemaVersion" .= (1 :: Int),+      "outcome" .= ("applied" :: Text),+      "applications" .= map (.unApplicationId) result.updatedApplications,+      "versions" .= map versionValue result.versions,+      "files" .= summaryValue result.fileSummary,+      "commands"+        .= object+          [ "executed" .= result.commandSummary.executed,+            "skippedUnchanged" .= result.commandSummary.skippedUnchanged,+            "skippedDisabled" .= result.commandSummary.skippedDisabled+          ],+      "touchedPaths" .= Set.toAscList result.touchedPaths,+      "warnings" .= map warningText result.warnings+    ]+outputValue (UpdateFailedOutput (UpdateErrorView err)) =+  object+    [ "schemaVersion" .= (1 :: Int),+      "outcome" .= ("error" :: Text),+      "error" .= object ["code" .= errorCode err, "message" .= errorMessage err]+    ]++versionLines :: UpdatePlan -> [Text]+versionLines plan+  | null plan.versionChanges = ["Versions:    unchanged"]+  | otherwise = map renderVersionChange plan.versionChanges++renderVersionChange :: VersionChange -> Text+renderVersionChange change =+  change.name+    <> "  "+    <> fromMaybe "unversioned" change.fromVersion+    <> " -> "+    <> fromMaybe "unversioned" change.toVersion+    <> if change.sameVersionContentChanged then " (content changed at same version)" else ""++renderInputs :: InputChangeSummary -> Text+renderInputs summary =+  "Inputs:      "+    <> count summary.reused+    <> " reused; "+    <> count summary.overridden+    <> " overridden; "+    <> count summary.newlyResolved+    <> " newly resolved; "+    <> count summary.removed+    <> " removed"++renderFiles :: ReconciliationSummary -> Text+renderFiles summary =+  "Files:       "+    <> count summary.creates+    <> " created; "+    <> count summary.updates+    <> " updated; "+    <> count summary.merged+    <> " merged; "+    <> count summary.unchanged+    <> " unchanged; "+    <> count summary.conflicts+    <> " conflicts; "+    <> count summary.safeDeletes+    <> " deleted; "+    <> count summary.editedOrphans+    <> " edited orphans"++renderCommands summary =+  "Commands:    "+    <> count summary.willRun+    <> " will run; "+    <> count summary.skippedUnchanged+    <> " unchanged skipped; "+    <> count summary.skippedDisabled+    <> " disabled"++migrationCaveat plan+  | any (.containsCommands) plan.migrations = " (includes non-simulatable commands)"+  | otherwise = ""++conflictLines :: ReconciliationPlan -> [Text]+conflictLines reconciliation = concatMap renderOne (Map.toAscList reconciliation.files)+  where+    renderOne (path, FileConflict _ _ _ reason _ _ resolution) =+      [ "Conflict:    "+          <> T.pack path+          <> " ("+          <> T.pack (show reason)+          <> maybe "; unresolved" (("; " <>) . resolutionText . (.choice)) resolution+          <> ")"+      ]+    renderOne (path, FileOrphanEdited _ _ _ _ choice) =+      [ "Orphan:      "+          <> T.pack path+          <> maybe " (unresolved)" ((" (" <>) . (<> ")") . orphanChoiceText) choice+      ]+    renderOne _ = []++warningLines :: [UpdateWarning] -> [Text]+warningLines = map (("Warning:     " <>) . warningText)++versionValue :: VersionChange -> Value+versionValue change =+  object+    [ "name" .= change.name,+      "from" .= change.fromVersion,+      "to" .= change.toVersion,+      "sameVersionContentChanged" .= change.sameVersionContentChanged+    ]++inputValue :: InputChangeSummary -> Value+inputValue summary =+  object+    [ "reused" .= summary.reused,+      "overridden" .= summary.overridden,+      "newlyResolved" .= summary.newlyResolved,+      "removed" .= summary.removed,+      "ambiguousLegacy" .= map (.unVarName) summary.ambiguousLegacy+    ]++migrationValue :: PlannedUpdateMigration -> Value+migrationValue migration =+  object+    [ "module" .= migration.moduleName.unModuleName,+      "from" .= showText migration.sourcePlan.planFrom,+      "to" .= showText migration.sourcePlan.planTo,+      "steps" .= length migration.sourcePlan.planSteps,+      "containsCommands" .= migration.containsCommands+    ]++fileValue :: (FilePath, FileReconciliation) -> Value+fileValue (path, reconciliation) =+  object+    [ "path" .= path,+      "classification" .= classification reconciliation,+      "resolution" .= resolutionFor reconciliation+    ]++classification :: FileReconciliation -> Text+classification FileCreate {} = "create"+classification FileUpdate {} = "update"+classification FileAutoMerge {} = "autoMerge"+classification FileUnchanged {} = "unchanged"+classification FileConflict {} = "conflict"+classification FileDeleteSafe {} = "safeDelete"+classification FileOrphanEdited {} = "editedOrphan"+classification FileReleaseSharedOwnership {} = "releaseSharedOwnership"+classification FileAlreadyAbsent {} = "alreadyAbsent"++resolutionFor :: FileReconciliation -> Maybe Text+resolutionFor (FileConflict _ _ _ _ _ _ resolution) = resolutionText . (.choice) <$> resolution+resolutionFor (FileOrphanEdited _ _ _ _ choice) = orphanChoiceText <$> choice+resolutionFor _ = Nothing++commandValue :: PlannedCommand -> Value+commandValue planned =+  object+    [ "fingerprint" .= fingerprintText planned.fingerprint,+      "status" .= dispositionText planned.disposition,+      "module" .= commandModule planned.operation,+      "command" .= commandText planned.operation+    ]++commandModule :: Operation -> Maybe Text+commandModule RunCommandOp {moduleName} = Just moduleName.unModuleName+commandModule _ = Nothing++commandText :: Operation -> Maybe Text+commandText RunCommandOp {command} = Just command+commandText _ = Nothing++dispositionText :: CommandDisposition -> Text+dispositionText CommandWillRun = "willRun"+dispositionText CommandSkippedUnchanged = "skippedUnchanged"+dispositionText CommandSkippedDisabled = "skippedDisabled"++resolutionText :: FileConflictChoice -> Text+resolutionText AcceptGenerated = "useGenerated"+resolutionText KeepCurrent = "keepCurrent"+resolutionText WriteConflictMarkers = "writeConflictMarkers"+resolutionText AbortUpdate = "abort"++orphanChoiceText :: OrphanChoice -> Text+orphanChoiceText DeleteEditedOrphan = "delete"+orphanChoiceText RetainTrackedOrphan = "retainTracked"+orphanChoiceText DetachAndKeepOrphan = "detachAndKeep"+orphanChoiceText AbortOrphanUpdate = "abort"++summaryValue :: ReconciliationSummary -> Value+summaryValue summary =+  object+    [ "created" .= summary.creates,+      "updated" .= summary.updates,+      "merged" .= summary.merged,+      "unchanged" .= summary.unchanged,+      "conflicts" .= summary.conflicts,+      "safeDeletes" .= summary.safeDeletes,+      "editedOrphans" .= summary.editedOrphans,+      "sharedOwnership" .= summary.sharedOwnership+    ]++applicationIdText :: AppliedComposition -> Text+applicationIdText application = application.applicationId.unApplicationId++fingerprintText :: CommandFingerprint -> Text+fingerprintText (CommandFingerprint (SHA256 value)) = value++warningText :: UpdateWarning -> Text+warningText = T.pack . show++errorCode :: UpdateError -> Text+errorCode UpdateManifestMissing {} = "manifest_missing"+errorCode UpdateManifestUnreadable {} = "manifest_unreadable"+errorCode NoRecordedApplications = "no_recorded_applications"+errorCode LegacyUpdateRequiresOneTarget = "legacy_update_requires_one_target"+errorCode UpdateTargetNotFound {} = "target_not_found"+errorCode SharedPathRequiresApplications {} = "shared_path_requires_applications"+errorCode CandidateCloneFailed {} = "candidate_clone_failed"+errorCode CandidateRepositoryInvalid {} = "candidate_repository_invalid"+errorCode CandidateArtifactMissing {} = "candidate_artifact_missing"+errorCode CandidateArtifactAmbiguous {} = "candidate_artifact_ambiguous"+errorCode CandidateLoadFailed {} = "candidate_load_failed"+errorCode CandidateDowngrade {} = "candidate_downgrade"+errorCode CandidateVersionInvalid {} = "candidate_version_invalid"+errorCode UpdateConflictingPriorVersions {} = "conflicting_prior_versions"+errorCode UpdateVariableErrors {} = "variable_errors"+errorCode UpdateConfigurationFailed {} = "configuration_failed"+errorCode UpdateMigrationPlanFailed {} = "migration_plan_failed"+errorCode UpdateMigrationStageFailed {} = "migration_stage_failed"+errorCode UpdateCompositionFailed {} = "composition_failed"+errorCode UpdateReconciliationFailed {} = "reconciliation_failed"+errorCode UpdateHasUnresolvedPaths {} = "unresolved_paths"+errorCode UpdateRecoveryFailed {} = "recovery_failed"+errorCode UpdatePlanStale {} = "plan_stale"+errorCode UpdateTransactionFailed {} = "transaction_failed"+errorCode UpdateMigrationFailed {} = "migration_failed"+errorCode UpdateChangedAfterMigrationCommand {} = "changed_after_migration_command"+errorCode UpdateCommandFailed {} = "command_failed"+errorCode UpdateCachePublicationFailed {} = "cache_publication_failed"+errorCode UpdateManifestWriteFailed {} = "manifest_write_failed"++errorMessage :: UpdateError -> Text+errorMessage (UpdateManifestMissing path) =+  "No Seihou manifest was found at " <> T.pack path <> ". Run seihou run first."+errorMessage (UpdateTargetNotFound target available) =+  "No recorded application matches '"+    <> target+    <> "'. Available targets: "+    <> T.intercalate ", " available+errorMessage (SharedPathRequiresApplications path selected required) =+  "Path "+    <> T.pack path+    <> " is also owned by application(s) "+    <> T.intercalate ", " (map (.unApplicationId) (Set.toAscList required))+    <> ". Select every owner or run seihou update with no targets. Selected: "+    <> T.intercalate ", " (map (.unApplicationId) (Set.toAscList selected))+errorMessage (UpdateHasUnresolvedPaths paths) =+  "Resolve these paths before apply: " <> T.intercalate ", " (map T.pack (Set.toAscList paths))+errorMessage (UpdatePlanStale paths) =+  "The project changed after planning: " <> T.intercalate ", " (map T.pack (Set.toAscList paths))+errorMessage err = T.pack (show err)++planLooksUnchanged :: UpdatePlan -> Bool+planLooksUnchanged plan =+  null plan.versionChanges+    && null plan.migrations+    && plan.inputChanges.overridden == 0+    && plan.inputChanges.newlyResolved == 0+    && plan.inputChanges.removed == 0+    && (summarizeCommandPlan plan.commandPlan).willRun == 0+    && all isUnchanged (Map.elems plan.reconciliation.files)+  where+    isUnchanged FileUnchanged {} = True+    isUnchanged _ = False++count :: Int -> Text+count = T.pack . show++showText :: (Show a) => a -> Text+showText = T.pack . show
+ src/Seihou/CLI/Update/Selection.hs view
@@ -0,0 +1,85 @@+module Seihou.CLI.Update.Selection+  ( SelectedApplications (..),+    selectApplications,+    targetName,+    availableTargets,+  )+where++import Control.Monad (foldM)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Seihou.CLI.Update.Types+import Seihou.Core.Types+import Seihou.Prelude++data SelectedApplications+  = RecordedSelection [AppliedComposition]+  | LegacySelection Text+  deriving stock (Eq, Show)++-- | Select applications in manifest order. Bare module names select every+-- recorded application containing that module instance; target names take+-- precedence for each requested name.+selectApplications :: UpdateSelection -> Manifest -> Either UpdateError SelectedApplications+selectApplications selection manifest = case selection of+  AllRecordedApplications+    | null manifest.applications -> Left NoRecordedApplications+    | otherwise -> Right (RecordedSelection manifest.applications)+  NamedUpdateTargets names+    | null manifest.applications -> case nubOrd names of+        [name] -> Right (LegacySelection name)+        _ -> Left LegacyUpdateRequiresOneTarget+    | otherwise -> do+        selectedIds <- foldM selectName Set.empty (nubOrd names)+        let selected = filter ((`Set.member` selectedIds) . (.applicationId)) manifest.applications+        ensureOwnershipClosure manifest selectedIds+        Right (RecordedSelection selected)+  where+    selectName selected name =+      let exact = filter ((== name) . targetName) manifest.applications+          matches =+            if null exact+              then filter (containsModule name) manifest.applications+              else exact+       in if null matches+            then Left (UpdateTargetNotFound name (availableTargets manifest))+            else Right (foldl' (flip (Set.insert . (.applicationId))) selected matches)++ensureOwnershipClosure :: Manifest -> Set ApplicationId -> Either UpdateError ()+ensureOwnershipClosure manifest selected =+  case [ (path, selectedOwners, missingOwners)+       | (path, record) <- Map.toAscList manifest.files,+         let selectedOwners = Set.intersection selected record.applicationIds,+         let missingOwners = record.applicationIds Set.\\ selected,+         not (Set.null selectedOwners),+         not (Set.null missingOwners)+       ] of+    (path, selectedOwners, missingOwners) : _ ->+      Left (SharedPathRequiresApplications path selectedOwners missingOwners)+    [] -> Right ()++targetName :: AppliedComposition -> Text+targetName application = case application.target of+  AppliedModuleTarget name -> name.unModuleName+  AppliedRecipeTarget name -> name.unRecipeName++availableTargets :: Manifest -> [Text]+availableTargets manifest = nubOrd (map targetName manifest.applications <> instanceNames)+  where+    instanceNames =+      [ state.name.unModuleName+      | application <- manifest.applications,+        state <- application.instances+      ]++containsModule :: Text -> AppliedComposition -> Bool+containsModule name = any ((== name) . (.name.unModuleName)) . (.instances)++nubOrd :: (Ord a) => [a] -> [a]+nubOrd = go Set.empty+  where+    go _ [] = []+    go seen (value : rest)+      | Set.member value seen = go seen rest+      | otherwise = value : go (Set.insert value seen) rest
+ src/Seihou/CLI/Update/Source.hs view
@@ -0,0 +1,360 @@+module Seihou.CLI.Update.Source+  ( stageCandidateSources,+    hashArtifactDirectory,+  )+where++import Control.Exception (SomeException, displayException, try)+import Control.Monad (foldM, forM)+import Data.ByteString qualified as BS+import Data.Foldable (traverse_)+import Data.List (sort)+import Data.Map.Strict qualified as Map+import Data.Text qualified as T+import Seihou.CLI.InstallShared+  ( OriginInfo (..),+    cloneRepo,+    copyDirectoryRecursive,+    readOriginInfo,+  )+import Seihou.CLI.Update.Types+import Seihou.Core.Module (validateModule)+import Seihou.Core.Recipe (validateRecipe)+import Seihou.Core.Registry (Registry (..), RegistryEntry (..), validateRegistry)+import Seihou.Core.Types+import Seihou.Dhall.Eval (evalModuleFromFile, evalRecipeFromFile, evalRegistryFromFile)+import Seihou.Manifest.Hash (hashContent)+import Seihou.Prelude+import System.Directory qualified as Directory+import System.Exit (ExitCode (..))+import System.Process (readProcessWithExitCode)++data ArtifactRequirement = ArtifactRequirement+  { kind :: CandidateArtifactKind,+    name :: Text,+    sourceDirectory :: FilePath,+    origin :: Maybe OriginInfo+  }++-- | Clone every distinct remote once, validate its complete module/recipe+-- catalog, and materialize a name-keyed temporary search root. Local artifacts+-- remain a fallback and are called out explicitly.+stageCandidateSources ::+  FilePath ->+  [AppliedComposition] ->+  IO (Either UpdateError (CandidateCatalog, [UpdateWarning]))+stageCandidateSources sessionRoot selected = do+  requirements <- requirementsFor selected+  let remoteOrigins =+        Map.fromList+          [ (origin.sourceUrl, origin)+          | requirement <- requirements,+            Just origin <- [requirement.origin]+          ]+      clonesRoot = sessionRoot </> "clones"+      searchRoot = sessionRoot </> "search"+  Directory.createDirectoryIfMissing True clonesRoot+  Directory.createDirectoryIfMissing True searchRoot+  remoteResult <- stageRemoteOrigins clonesRoot searchRoot (Map.toAscList remoteOrigins)+  case remoteResult of+    Left err -> pure (Left err)+    Right (remoteArtifacts, clones) -> do+      localResult <- stageLocalRequirements searchRoot remoteArtifacts requirements+      pure $ do+        (allArtifacts, localWarnings) <- localResult+        verifyRemoteRequirements requirements allArtifacts+        Right+          ( CandidateCatalog+              { searchRoot,+                artifacts = allArtifacts,+                clonedOrigins = clones+              },+            localWarnings+          )++requirementsFor :: [AppliedComposition] -> IO [ArtifactRequirement]+requirementsFor applications = concat <$> mapM applicationRequirements applications+  where+    applicationRequirements application = do+      targetOrigin <- readOriginInfo application.targetSource+      instanceRequirements <- forM application.instances $ \state -> do+        origin <- readOriginInfo state.source+        pure+          ArtifactRequirement+            { kind = CandidateModule,+              name = state.name.unModuleName,+              sourceDirectory = state.source,+              origin+            }+      let targetRequirement = case application.target of+            AppliedModuleTarget name ->+              ArtifactRequirement CandidateModule name.unModuleName application.targetSource targetOrigin+            AppliedRecipeTarget name ->+              ArtifactRequirement CandidateRecipe name.unRecipeName application.targetSource targetOrigin+      pure (targetRequirement : instanceRequirements)++stageRemoteOrigins ::+  FilePath ->+  FilePath ->+  [(Text, OriginInfo)] ->+  IO+    ( Either+        UpdateError+        (Map (CandidateArtifactKind, Text) CandidateArtifact, Map Text FilePath)+    )+stageRemoteOrigins clonesRoot searchRoot = go 0 Map.empty Map.empty+  where+    go _ artifacts clones [] = pure (Right (artifacts, clones))+    go index artifacts clones ((url, origin) : rest) = do+      let cloneDirectory = clonesRoot </> show index+      cloneResult <- cloneRepo url cloneDirectory+      case cloneResult of+        Left message -> pure (Left (CandidateCloneFailed url message))+        Right () -> do+          revision <- gitRevision cloneDirectory+          discovered <- discoverRemote url origin revision cloneDirectory+          case discovered of+            Left err -> pure (Left err)+            Right candidates -> do+              inserted <- foldM (insertCandidate searchRoot) (Right artifacts) candidates+              case inserted of+                Left err -> pure (Left err)+                Right artifacts' ->+                  go (index + 1) artifacts' (Map.insert url cloneDirectory clones) rest++discoverRemote ::+  Text ->+  OriginInfo ->+  Maybe Text ->+  FilePath ->+  IO (Either UpdateError [CandidateArtifact])+discoverRemote url origin revision repoRoot = do+  let registryFile = repoRoot </> "seihou-registry.dhall"+  hasRegistry <- Directory.doesFileExist registryFile+  if hasRegistry+    then do+      decoded <- evalRegistryFromFile registryFile+      case decoded of+        Left err -> pure (Left (CandidateLoadFailed (T.pack registryFile) err))+        Right registry -> do+          validationErrors <- validateRegistry repoRoot registry+          if null validationErrors+            then discoverRegistryArtifacts url revision repoRoot registry+            else pure (Left (CandidateRepositoryInvalid url validationErrors))+    else discoverSingleArtifact url origin revision repoRoot++discoverRegistryArtifacts ::+  Text ->+  Maybe Text ->+  FilePath ->+  Registry ->+  IO (Either UpdateError [CandidateArtifact])+discoverRegistryArtifacts url revision repoRoot registry = do+  modules <- traverse (loadRemoteModule url (Just registry.repoName) revision repoRoot) registry.modules+  recipes <- traverse (loadRemoteRecipe url (Just registry.repoName) revision repoRoot) registry.recipes+  pure ((<>) <$> sequence modules <*> sequence recipes)++discoverSingleArtifact ::+  Text ->+  OriginInfo ->+  Maybe Text ->+  FilePath ->+  IO (Either UpdateError [CandidateArtifact])+discoverSingleArtifact url origin revision repoRoot = do+  hasModule <- Directory.doesFileExist (repoRoot </> "module.dhall")+  hasRecipe <- Directory.doesFileExist (repoRoot </> "recipe.dhall")+  if hasModule+    then fmap (fmap (: [])) (loadModuleArtifact (Just url) origin.repoName [] revision repoRoot)+    else+      if hasRecipe+        then fmap (fmap (: [])) (loadRecipeArtifact (Just url) origin.repoName [] revision repoRoot)+        else pure (Left (CandidateRepositoryInvalid url ["repository contains no module, recipe, or registry"]))++loadRemoteModule ::+  Text -> Maybe Text -> Maybe Text -> FilePath -> RegistryEntry -> IO (Either UpdateError CandidateArtifact)+loadRemoteModule url repoName revision repoRoot entry =+  loadModuleArtifact (Just url) repoName entry.tags revision (repoRoot </> entry.path)++loadRemoteRecipe ::+  Text -> Maybe Text -> Maybe Text -> FilePath -> RegistryEntry -> IO (Either UpdateError CandidateArtifact)+loadRemoteRecipe url repoName revision repoRoot entry =+  loadRecipeArtifact (Just url) repoName entry.tags revision (repoRoot </> entry.path)++loadModuleArtifact ::+  Maybe Text -> Maybe Text -> [Text] -> Maybe Text -> FilePath -> IO (Either UpdateError CandidateArtifact)+loadModuleArtifact sourceUrl repoName tags revision directory = do+  decoded <- evalModuleFromFile (directory </> "module.dhall")+  case decoded of+    Left err -> pure (Left (CandidateLoadFailed (T.pack directory) err))+    Right modul -> do+      validated <- validateModule directory modul+      case validated of+        Left err -> pure (Left (CandidateLoadFailed modul.name.unModuleName err))+        Right candidateModule -> do+          contentHash <- hashArtifactDirectory directory+          pure+            ( Right+                CandidateArtifact+                  { kind = CandidateModule,+                    name = candidateModule.name.unModuleName,+                    version = candidateModule.version,+                    originalDirectory = directory,+                    sourceDirectory = directory,+                    sourceUrl,+                    repoName,+                    tags,+                    sourceRevision = revision,+                    contentHash,+                    moduleDefinition = Just candidateModule,+                    recipeDefinition = Nothing+                  }+            )++loadRecipeArtifact ::+  Maybe Text -> Maybe Text -> [Text] -> Maybe Text -> FilePath -> IO (Either UpdateError CandidateArtifact)+loadRecipeArtifact sourceUrl repoName tags revision directory = do+  decoded <- evalRecipeFromFile (directory </> "recipe.dhall")+  case first (CandidateLoadFailed (T.pack directory)) decoded of+    Left err -> pure (Left err)+    Right recipe -> case first (CandidateRepositoryInvalid (maybe "local" id sourceUrl)) (validateRecipe recipe) of+      Left err -> pure (Left err)+      Right validated -> do+        contentHash <- hashArtifactDirectory directory+        pure+          ( Right+              CandidateArtifact+                { kind = CandidateRecipe,+                  name = validated.name.unRecipeName,+                  version = validated.version,+                  originalDirectory = directory,+                  sourceDirectory = directory,+                  sourceUrl,+                  repoName,+                  tags,+                  sourceRevision = revision,+                  contentHash,+                  moduleDefinition = Nothing,+                  recipeDefinition = Just validated+                }+          )++stageLocalRequirements ::+  FilePath ->+  Map (CandidateArtifactKind, Text) CandidateArtifact ->+  [ArtifactRequirement] ->+  IO (Either UpdateError (Map (CandidateArtifactKind, Text) CandidateArtifact, [UpdateWarning]))+stageLocalRequirements searchRoot initial = go initial []+  where+    go artifacts warnings [] = pure (Right (artifacts, reverse warnings))+    go artifacts warnings (requirement : rest) = case requirement.origin of+      Just _ -> go artifacts warnings rest+      Nothing+        | Map.member (requirement.kind, requirement.name) artifacts -> go artifacts warnings rest+        | otherwise -> do+            loaded <- case requirement.kind of+              CandidateModule -> loadModuleArtifact Nothing Nothing [] Nothing requirement.sourceDirectory+              CandidateRecipe -> loadRecipeArtifact Nothing Nothing [] Nothing requirement.sourceDirectory+            case loaded of+              Left err -> pure (Left err)+              Right candidate -> do+                inserted <- insertCandidate searchRoot (Right artifacts) candidate+                case inserted of+                  Left err -> pure (Left err)+                  Right artifacts' ->+                    go artifacts' (LocalArtifactHasNoRemote requirement.name : warnings) rest++insertCandidate ::+  FilePath ->+  Either UpdateError (Map (CandidateArtifactKind, Text) CandidateArtifact) ->+  CandidateArtifact ->+  IO (Either UpdateError (Map (CandidateArtifactKind, Text) CandidateArtifact))+insertCandidate _ (Left err) _ = pure (Left err)+insertCandidate searchRoot (Right artifacts) candidate =+  case Map.lookup key artifacts of+    Just existing ->+      pure+        ( Left+            ( CandidateArtifactAmbiguous+                candidate.kind+                candidate.name+                (map (maybe "local" id . (.sourceUrl)) [existing, candidate])+            )+        )+    Nothing -> do+      let destination = searchRoot </> T.unpack candidate.name+      Directory.createDirectoryIfMissing True destination+      copied <- try @SomeException (copyDirectoryRecursive candidate.sourceDirectory destination)+      pure $ case copied of+        Left err -> Left (CandidateRepositoryInvalid candidate.name [T.pack (displayException err)])+        Right () ->+          Right+            ( Map.insert+                key+                (setCandidateSource destination candidate)+                artifacts+            )+  where+    key = (candidate.kind, candidate.name)++setCandidateSource :: FilePath -> CandidateArtifact -> CandidateArtifact+setCandidateSource directory candidate =+  CandidateArtifact+    { kind = candidate.kind,+      name = candidate.name,+      version = candidate.version,+      originalDirectory = candidate.originalDirectory,+      sourceDirectory = directory,+      sourceUrl = candidate.sourceUrl,+      repoName = candidate.repoName,+      tags = candidate.tags,+      sourceRevision = candidate.sourceRevision,+      contentHash = candidate.contentHash,+      moduleDefinition = candidate.moduleDefinition,+      recipeDefinition = candidate.recipeDefinition+    }++verifyRemoteRequirements ::+  [ArtifactRequirement] ->+  Map (CandidateArtifactKind, Text) CandidateArtifact ->+  Either UpdateError ()+verifyRemoteRequirements requirements artifacts = traverse_ verify requirements+  where+    verify requirement = case Map.lookup (requirement.kind, requirement.name) artifacts of+      Nothing -> Left (CandidateArtifactMissing requirement.kind requirement.name)+      Just candidate -> case requirement.origin of+        Nothing -> Right ()+        Just origin+          | candidate.sourceUrl == Just origin.sourceUrl -> Right ()+          | otherwise -> Left (CandidateArtifactMissing requirement.kind requirement.name)++gitRevision :: FilePath -> IO (Maybe Text)+gitRevision directory = do+  result <- try @SomeException (readProcessWithExitCode "git" ["-C", directory, "rev-parse", "HEAD"] "")+  pure $ case result of+    Right (ExitSuccess, stdout, _) -> Just (T.strip (T.pack stdout))+    _ -> Nothing++hashArtifactDirectory :: FilePath -> IO SHA256+hashArtifactDirectory root = do+  entries <- collectFiles root ""+  chunks <- forM entries $ \relative -> do+    bytes <- BS.readFile (root </> relative)+    pure (T.pack relative <> "\NUL" <> T.pack (show bytes))+  pure (hashContent (T.intercalate "\NUL" chunks))+  where+    collectFiles base relative = do+      let directory = if null relative then base else base </> relative+      names <- sort <$> Directory.listDirectory directory+      fmap concat $ forM names $ \name -> do+        let childRelative = if null relative then name else relative </> name+            child = base </> childRelative+        isDirectory <- Directory.doesDirectoryExist child+        if name == ".seihou-origin.json"+          then pure []+          else+            if isDirectory+              then+                if name == ".git"+                  then pure []+                  else collectFiles base childRelative+              else pure [childRelative]
+ src/Seihou/CLI/Update/Types.hs view
@@ -0,0 +1,215 @@+module Seihou.CLI.Update.Types+  ( UpdateSelection (..),+    PromptPolicy (..),+    UpdateRequest (..),+    VersionChange (..),+    InputChangeSummary (..),+    CandidateArtifactKind (..),+    CandidateArtifact (..),+    CandidateCatalog (..),+    PlannedUpdateMigration (..),+    PlannedApplication (..),+    UpdateSnapshot (..),+    UpdateWarning (..),+    UpdatePlan (..),+    CommandSummary (..),+    UpdateResult (..),+    UpdateError (..),+  )+where++import Data.Map.Strict (Map)+import Data.Set (Set)+import Seihou.CLI.CommandExecution+  ( CommandExecutionError,+    CommandPlan,+    CommandPolicy,+  )+import Seihou.Composition.Instance (ModuleInstance)+import Seihou.Core.Migration (MigrationPlan, MigrationPlanError)+import Seihou.Core.Types+import Seihou.Engine.Migrate (ExecutedMigrationPlan, MigrationExecError)+import Seihou.Engine.Reconcile+  ( DesiredFileOwner,+    ReconciliationError,+    ReconciliationPlan,+    ReconciliationSummary,+  )+import Seihou.Engine.UpdateTransaction (TransactionError)+import Seihou.Prelude++data UpdateSelection+  = AllRecordedApplications+  | NamedUpdateTargets [Text]+  deriving stock (Eq, Show)++data PromptPolicy+  = AllowPrompts+  | ForbidPrompts+  deriving stock (Eq, Show)++data UpdateRequest = UpdateRequest+  { selection :: UpdateSelection,+    varOverrides :: [(Text, Text)],+    reconfigure :: Bool,+    promptPolicy :: PromptPolicy,+    commandPolicy :: CommandPolicy,+    dryRun :: Bool+  }+  deriving stock (Eq, Show)++data VersionChange = VersionChange+  { name :: Text,+    fromVersion :: Maybe Text,+    toVersion :: Maybe Text,+    sameVersionContentChanged :: Bool+  }+  deriving stock (Eq, Show)++data InputChangeSummary = InputChangeSummary+  { reused :: Int,+    overridden :: Int,+    newlyResolved :: Int,+    removed :: Int,+    ambiguousLegacy :: [VarName]+  }+  deriving stock (Eq, Show)++data CandidateArtifactKind = CandidateModule | CandidateRecipe+  deriving stock (Eq, Ord, Show)++-- | One validated artifact staged for this update session. The source path is+-- temporary for remote candidates and must not escape 'withProjectUpdate'.+data CandidateArtifact = CandidateArtifact+  { kind :: CandidateArtifactKind,+    name :: Text,+    version :: Maybe Text,+    originalDirectory :: FilePath,+    sourceDirectory :: FilePath,+    sourceUrl :: Maybe Text,+    repoName :: Maybe Text,+    tags :: [Text],+    sourceRevision :: Maybe Text,+    contentHash :: SHA256,+    moduleDefinition :: Maybe Module,+    recipeDefinition :: Maybe Recipe+  }+  deriving stock (Eq, Show)++data CandidateCatalog = CandidateCatalog+  { searchRoot :: FilePath,+    artifacts :: Map (CandidateArtifactKind, Text) CandidateArtifact,+    clonedOrigins :: Map Text FilePath+  }+  deriving stock (Eq, Show)++data PlannedUpdateMigration = PlannedUpdateMigration+  { moduleName :: ModuleName,+    sourceDirectory :: FilePath,+    sourcePlan :: MigrationPlan,+    stagedPlan :: ExecutedMigrationPlan,+    containsCommands :: Bool+  }+  deriving stock (Eq, Show)++-- | Internal, renderer-neutral material retained so apply can re-plan after+-- migration commands without reusing parser state.+data PlannedApplication = PlannedApplication+  { previous :: Maybe AppliedComposition,+    candidate :: AppliedComposition,+    modulesInOrder :: [(ModuleInstance, Module, FilePath)],+    resolvedValues :: Map ModuleInstance (Map VarName ResolvedVar),+    operations :: [Operation],+    desiredOwners :: Map FilePath DesiredFileOwner+  }+  deriving stock (Eq, Show)++data UpdateSnapshot = UpdateSnapshot+  { sessionDirectory :: FilePath,+    projectRoot :: FilePath,+    manifestPath :: FilePath,+    baselineDirectory :: FilePath,+    installedDirectory :: FilePath,+    originalManifest :: Manifest,+    candidateHashes :: Map FilePath SHA256,+    observedProjectHashes :: Map FilePath (Maybe SHA256),+    transactionTargets :: Set FilePath+  }+  deriving stock (Eq, Show)++data UpdateWarning+  = LocalArtifactHasNoRemote Text+  | SameVersionContentChanged Text+  | AmbiguousLegacyValue VarName+  | MissingLegacyValue VarName+  | MigrationCommandNotSimulated ModuleName Text+  | CrossApplicationLastWriter FilePath ModuleName ModuleName+  | ArbitraryCommandSideEffectsMayRemain+  | BaselinePruneFailed Text+  | RecoveryCleanupDeferred Text+  deriving stock (Eq, Show)++data UpdatePlan = UpdatePlan+  { applications :: [AppliedComposition],+    versionChanges :: [VersionChange],+    inputChanges :: InputChangeSummary,+    migrations :: [PlannedUpdateMigration],+    reconciliation :: ReconciliationPlan,+    commandPlan :: CommandPlan,+    candidateArtifacts :: [CandidateArtifact],+    warnings :: [UpdateWarning],+    request :: UpdateRequest,+    snapshot :: UpdateSnapshot,+    plannedApplications :: [PlannedApplication]+  }+  deriving stock (Eq, Show)++data CommandSummary = CommandSummary+  { executed :: Int,+    skippedUnchanged :: Int,+    skippedDisabled :: Int+  }+  deriving stock (Eq, Show)++data UpdateResult = UpdateResult+  { updatedApplications :: [ApplicationId],+    manifest :: Manifest,+    versions :: [VersionChange],+    fileSummary :: ReconciliationSummary,+    commandSummary :: CommandSummary,+    touchedPaths :: Set FilePath,+    warnings :: [UpdateWarning]+  }+  deriving stock (Eq, Show)++data UpdateError+  = UpdateManifestMissing FilePath+  | UpdateManifestUnreadable FilePath Text+  | NoRecordedApplications+  | LegacyUpdateRequiresOneTarget+  | UpdateTargetNotFound Text [Text]+  | SharedPathRequiresApplications FilePath (Set ApplicationId) (Set ApplicationId)+  | CandidateCloneFailed Text Text+  | CandidateRepositoryInvalid Text [Text]+  | CandidateArtifactMissing CandidateArtifactKind Text+  | CandidateArtifactAmbiguous CandidateArtifactKind Text [Text]+  | CandidateLoadFailed Text ModuleLoadError+  | CandidateDowngrade Text (Maybe Text) (Maybe Text)+  | CandidateVersionInvalid Text Text+  | UpdateConflictingPriorVersions ModuleName [Text]+  | UpdateVariableErrors [VarError]+  | UpdateConfigurationFailed Text+  | UpdateMigrationPlanFailed ModuleName MigrationPlanError+  | UpdateMigrationStageFailed ModuleName MigrationExecError+  | UpdateCompositionFailed [Text]+  | UpdateReconciliationFailed ReconciliationError+  | UpdateHasUnresolvedPaths (Set FilePath)+  | UpdateRecoveryFailed [TransactionError]+  | UpdatePlanStale (Set FilePath)+  | UpdateTransactionFailed TransactionError+  | UpdateMigrationFailed ModuleName MigrationExecError+  | UpdateChangedAfterMigrationCommand ReconciliationSummary ReconciliationSummary+  | UpdateCommandFailed CommandExecutionError [UpdateWarning]+  | UpdateCachePublicationFailed Text+  | UpdateManifestWriteFailed Text+  deriving stock (Eq, Show)
test/Main.hs view
@@ -1,10 +1,16 @@ module Main (main) where  import Seihou.CLI.AgentCompletionSpec qualified as AgentCompletionSpec+import Seihou.CLI.AgentConfigShowSpec qualified as AgentConfigShowSpec import Seihou.CLI.AgentConfigSpec qualified as AgentConfigSpec import Seihou.CLI.AgentLaunchSpec qualified as AgentLaunchSpec+import Seihou.CLI.AgentMigrateE2ESpec qualified as AgentMigrateE2ESpec+import Seihou.CLI.AgentModelsSpec qualified as AgentModelsSpec+import Seihou.CLI.AppliedBlueprintMigrationSpec qualified as AppliedBlueprintMigrationSpec import Seihou.CLI.AppliedBlueprintSpec qualified as AppliedBlueprintSpec+import Seihou.CLI.BlueprintMigrationSpec qualified as BlueprintMigrationSpec import Seihou.CLI.BrowseFormatSpec qualified as BrowseFormatSpec+import Seihou.CLI.CommandExecutionSpec qualified as CommandExecutionSpec import Seihou.CLI.CommitMessageSpec qualified as CommitMessageSpec import Seihou.CLI.DiffSpec qualified as DiffSpec import Seihou.CLI.ExtensionSpec qualified as ExtensionSpec@@ -21,6 +27,10 @@ import Seihou.CLI.RunBlueprintRefusalSpec qualified as RunBlueprintRefusalSpec import Seihou.CLI.SavePromptedSpec qualified as SavePromptedSpec import Seihou.CLI.StatusSpec qualified as StatusSpec+import Seihou.CLI.UpdateE2ESpec qualified as UpdateE2ESpec+import Seihou.CLI.UpdateInteractionSpec qualified as UpdateInteractionSpec+import Seihou.CLI.UpdateRenderSpec qualified as UpdateRenderSpec+import Seihou.CLI.UpdateSpec qualified as UpdateSpec import Seihou.CLI.UpgradeSpec qualified as UpgradeSpec import Seihou.FzfSpec qualified as FzfSpec import Test.Tasty@@ -30,11 +40,17 @@   tests <-     sequence       [ AgentLaunchSpec.tests,+        AgentMigrateE2ESpec.tests,         AgentCompletionSpec.tests,         AgentConfigSpec.tests,+        AgentConfigShowSpec.tests,+        AgentModelsSpec.tests,         AppliedBlueprintSpec.tests,+        AppliedBlueprintMigrationSpec.tests,+        BlueprintMigrationSpec.tests,         BrowseFormatSpec.tests,         CommitMessageSpec.tests,+        CommandExecutionSpec.tests,         DiffSpec.tests,         ExtensionSpec.tests,         GitSpec.tests,@@ -51,6 +67,10 @@         SavePromptedSpec.tests,         StatusSpec.tests,         UpgradeSpec.tests,+        UpdateSpec.tests,+        UpdateInteractionSpec.tests,+        UpdateE2ESpec.tests,+        UpdateRenderSpec.tests,         FzfSpec.tests       ]   defaultMain (testGroup "seihou-cli" tests)
test/Seihou/CLI/AgentCompletionSpec.hs view
@@ -40,7 +40,8 @@       defaultAgentModelConfig         `shouldBe` AgentModelConfig           { agentProvider = AgentProviderClaudeCli,-            agentModel = Nothing+            agentModel = Nothing,+            agentEffort = Nothing           }      it "builds a Claude CLI model using the CLI API tag" $ do@@ -48,7 +49,8 @@             buildBaikaiModel               AgentModelConfig                 { agentProvider = AgentProviderClaudeCli,-                  agentModel = Just "sonnet"+                  agentModel = Just "sonnet",+                  agentEffort = Nothing                 }       BaikaiModel.api model `shouldBe` Baikai.AnthropicMessagesCli       BaikaiModel.provider model `shouldBe` "anthropic"@@ -59,7 +61,8 @@             buildBaikaiModel               AgentModelConfig                 { agentProvider = AgentProviderCodexCli,-                  agentModel = Just "gpt-5"+                  agentModel = Just "gpt-5",+                  agentEffort = Nothing                 }       BaikaiModel.api model `shouldBe` Baikai.OpenAICompletionsCli       BaikaiModel.provider model `shouldBe` "openai"@@ -70,7 +73,8 @@       let config =             AgentModelConfig               { agentProvider = AgentProviderCodexCli,-                agentModel = Just "gpt-5"+                agentModel = Just "gpt-5",+                agentEffort = Nothing               }       buildAgentCompletionRequest config "system" (Just "user")         `shouldBe` AgentCompletionRequest
+ test/Seihou/CLI/AgentConfigShowSpec.hs view
@@ -0,0 +1,81 @@+module Seihou.CLI.AgentConfigShowSpec (tests) where++import Baikai.ThinkingLevel (ThinkingLevel (..))+import Data.Text (Text)+import Data.Text qualified as Text+import Seihou.CLI.AgentCompletion (AgentProvider (..))+import Seihou.CLI.AgentConfig+  ( AgentCommandName (..),+    AgentConfigSource (..),+    ResolvedAgentField (..),+    ResolvedCommandConfig (..),+  )+import Seihou.CLI.AgentConfigShow (formatResolvedAgentConfig)+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Seihou.CLI.AgentConfigShow" spec++spec :: Spec+spec =+  describe "formatResolvedAgentConfig" $ do+    let rendered = formatResolvedAgentConfig sample+        -- A line exists containing every fragment (order-independent).+        hasLine fragments =+          any (\ln -> all (`Text.isInfixOf` ln) fragments) (Text.lines rendered)++    -- The command label is printed on the provider row; the model row's label+    -- column is blank, so model-row assertions match on value + source only+    -- (each is unique to one command in the sample).+    it "labels a per-command local model with its concrete key" $+      hasLine ["model", "claude-opus-4-8", "[local: agent.run.model]"] `shouldBe` True++    it "shows an unset model as (default) with built-in provenance" $+      hasLine ["model", "(default)", "[built-in default]"] `shouldBe` True++    it "labels a global default model with the shared key" $+      hasLine ["model", "claude-sonnet-5", "[global: agent.model]"] `shouldBe` True++    it "labels a global per-command provider with its command key on the labelled row" $+      hasLine ["assist", "provider", "codex-cli", "[global: agent.assist.provider]"] `shouldBe` True++    it "renders the migrate command and its concrete config key" $+      hasLine ["migrate", "model", "gpt-5-mini", "[local: agent.migrate.model]"] `shouldBe` True++    it "labels a per-command local effort with its concrete key" $+      hasLine ["effort", "max", "[local: agent.run.effort]"] `shouldBe` True++    it "labels a global default effort with the shared key" $+      hasLine ["effort", "high", "[global: agent.effort]"] `shouldBe` True++    it "shows an unset effort as (default) with built-in provenance" $+      hasLine ["effort", "(default)", "[built-in default]"] `shouldBe` True++    it "includes the precedence legend" $+      ("Precedence, highest first:" `Text.isInfixOf` rendered) `shouldBe` True++sample :: [ResolvedCommandConfig]+sample =+  [ ResolvedCommandConfig+      AgentCmdAssist+      (ResolvedAgentField AgentProviderCodexCli SourceGlobalCommand)+      (ResolvedAgentField Nothing SourceBuiltinDefault)+      (ResolvedAgentField (Just ThinkingHigh) SourceGlobalDefault),+    ResolvedCommandConfig+      AgentCmdBootstrap+      (ResolvedAgentField AgentProviderClaudeCli SourceBuiltinDefault)+      (ResolvedAgentField (Just "claude-sonnet-5") SourceGlobalDefault)+      (ResolvedAgentField Nothing SourceBuiltinDefault),+    ResolvedCommandConfig+      AgentCmdRun+      (ResolvedAgentField AgentProviderClaudeCli SourceBuiltinDefault)+      (ResolvedAgentField (Just "claude-opus-4-8") SourceLocalCommand)+      (ResolvedAgentField (Just ThinkingMax) SourceLocalCommand),+    ResolvedCommandConfig+      AgentCmdMigrate+      (ResolvedAgentField AgentProviderOpenAI SourceLocalCommand)+      (ResolvedAgentField (Just "gpt-5-mini") SourceLocalCommand)+      (ResolvedAgentField Nothing SourceBuiltinDefault)+  ]
test/Seihou/CLI/AgentConfigSpec.hs view
@@ -1,5 +1,6 @@ module Seihou.CLI.AgentConfigSpec (tests) where +import Baikai.ThinkingLevel (ThinkingLevel (..)) import Data.Map.Strict qualified as Map import Data.Text (Text) import Data.Text qualified as Text@@ -15,36 +16,29 @@ spec :: Spec spec = do   describe "resolveAgentModelConfig" $ do-    it "uses CLI flags before environment variables" $ do+    it "uses CLI flags before environment variables" $       resolveAgentModelConfig         (baseInputs {cliProvider = Just "codex-cli", cliModel = Just "gpt-5", envProvider = Just "anthropic", envModel = Just "claude-sonnet-4-6"})-        `shouldBe` Right-          AgentModelConfig-            { agentProvider = AgentProviderCodexCli,-              agentModel = Just "gpt-5"-            }+        `shouldBe` Right (cfg AgentProviderCodexCli (Just "gpt-5")) -    it "uses environment variables before local config" $ do+    it "uses environment variables before local config" $       resolveAgentModelConfig         (baseInputs {envProvider = Just "openai", envModel = Just "gpt-4o", localConfig = config "anthropic" "claude-sonnet-4-6"})-        `shouldBe` Right-          AgentModelConfig-            { agentProvider = AgentProviderOpenAI,-              agentModel = Just "gpt-4o"-            }+        `shouldBe` Right (cfg AgentProviderOpenAI (Just "gpt-4o")) -    it "uses local config before global config" $ do+    it "uses local config before global config" $       resolveAgentModelConfig         (baseInputs {localConfig = config "anthropic" "claude-opus-4-1", globalConfig = config "openai" "gpt-4o-mini"})-        `shouldBe` Right-          AgentModelConfig-            { agentProvider = AgentProviderAnthropic,-              agentModel = Just "claude-opus-4-1"-            }+        `shouldBe` Right (cfg AgentProviderAnthropic (Just "claude-opus-4-1")) -    it "falls back to the Claude CLI default" $-      resolveAgentModelConfig baseInputs `shouldBe` Right defaultAgentModelConfig+    it "pins the deterministic claude-cli default model when nothing is set" $+      resolveAgentModelConfig baseInputs+        `shouldBe` Right (cfg AgentProviderClaudeCli (Just "claude-opus-4-8")) +    it "pins the deterministic codex-cli default model when only the provider is set" $+      resolveAgentModelConfig (baseInputs {cliProvider = Just "codex-cli"})+        `shouldBe` Right (cfg AgentProviderCodexCli (Just "gpt-5.6-terra"))+     it "returns provider diagnostics for invalid provider text" $       resolveAgentModelConfig (baseInputs {cliProvider = Just "llama"}) `shouldSatisfy` \case         Left err ->@@ -55,31 +49,151 @@      it "allows a model-only override while keeping the default provider" $       resolveAgentModelConfig (baseInputs {cliModel = Just "sonnet"})-        `shouldBe` Right-          AgentModelConfig-            { agentProvider = AgentProviderClaudeCli,-              agentModel = Just "sonnet"-            }+        `shouldBe` Right (cfg AgentProviderClaudeCli (Just "sonnet"))      it "ignores blank higher-precedence values" $       resolveAgentModelConfig         (baseInputs {cliProvider = Just "  ", envProvider = Just "codex-cli", cliModel = Just "", envModel = Just "gpt-5"})-        `shouldBe` Right-          AgentModelConfig-            { agentProvider = AgentProviderCodexCli,-              agentModel = Just "gpt-5"-            }+        `shouldBe` Right (cfg AgentProviderCodexCli (Just "gpt-5")) +  describe "resolveAgentModelConfigFor (per-command)" $ do+    it "prefers a per-command model over the default in the same scope" $ do+      let inputs =+            baseInputs+              { localConfig =+                  Map.fromList+                    [ (agentModelConfigKey, "claude-sonnet-5"),+                      (agentCommandModelConfigKey AgentCmdRun, "claude-opus-4-8")+                    ]+              }+      modelOf AgentCmdRun inputs `shouldBe` Right (Just "claude-opus-4-8", SourceLocalCommand)+      modelOf AgentCmdAssist inputs `shouldBe` Right (Just "claude-sonnet-5", SourceLocalDefault)++    it "lets a local default override a global per-command key (project over global)" $ do+      let inputs =+            baseInputs+              { localConfig = Map.fromList [(agentModelConfigKey, "claude-sonnet-5")],+                globalConfig = Map.fromList [(agentCommandModelConfigKey AgentCmdRun, "gpt-5")]+              }+      modelOf AgentCmdRun inputs `shouldBe` Right (Just "claude-sonnet-5", SourceLocalDefault)++    it "prefers a global per-command key over the global default" $ do+      let inputs =+            baseInputs+              { globalConfig =+                  Map.fromList+                    [ (agentProviderConfigKey, "anthropic"),+                      (agentCommandProviderConfigKey AgentCmdAssist, "openai")+                    ]+              }+      providerOf AgentCmdAssist inputs `shouldBe` Right (AgentProviderOpenAI, SourceGlobalCommand)+      providerOf AgentCmdSetup inputs `shouldBe` Right (AgentProviderAnthropic, SourceGlobalDefault)++    it "labels a subcommand CLI flag distinctly from a parent flag" $ do+      providerOf AgentCmdAssist (baseInputs {cliProvider = Just "codex-cli", cliProviderFromSubcommand = True})+        `shouldBe` Right (AgentProviderCodexCli, SourceCliSubcommand)+      providerOf AgentCmdAssist (baseInputs {cliProvider = Just "codex-cli", cliProviderFromSubcommand = False})+        `shouldBe` Right (AgentProviderCodexCli, SourceCliParent)++    it "falls back to the pinned CLI default model with built-in provenance" $ do+      providerOf AgentCmdRun baseInputs `shouldBe` Right (AgentProviderClaudeCli, SourceBuiltinDefault)+      modelOf AgentCmdRun baseInputs `shouldBe` Right (Just "claude-opus-4-8", SourceBuiltinDefault)++    it "keeps claude-cli and codex-cli deterministic: model is never Nothing" $ do+      -- With nothing configured, every command resolves to a concrete model for+      -- both local CLI providers, so seihou always passes an explicit --model.+      modelOf AgentCmdAssist baseInputs `shouldBe` Right (Just "claude-opus-4-8", SourceBuiltinDefault)+      modelOf AgentCmdAssist (baseInputs {cliProvider = Just "codex-cli", cliProviderFromSubcommand = True})+        `shouldBe` Right (Just "gpt-5.6-terra", SourceBuiltinDefault)++    it "keeps environment variables above per-command config" $ do+      let inputs =+            baseInputs+              { envModel = Just "gpt-5",+                localConfig = Map.fromList [(agentCommandModelConfigKey AgentCmdRun, "claude-opus-4-8")]+              }+      modelOf AgentCmdRun inputs `shouldBe` Right (Just "gpt-5", SourceEnv)++    it "resolves migrate from its own per-command keys" $ do+      let inputs =+            baseInputs+              { localConfig =+                  Map.fromList+                    [ (agentCommandProviderConfigKey AgentCmdMigrate, "openai"),+                      (agentCommandModelConfigKey AgentCmdMigrate, "gpt-5-mini"),+                      (agentCommandModelConfigKey AgentCmdRun, "claude-opus-4-8")+                    ]+              }+      providerOf AgentCmdMigrate inputs `shouldBe` Right (AgentProviderOpenAI, SourceLocalCommand)+      modelOf AgentCmdMigrate inputs `shouldBe` Right (Just "gpt-5-mini", SourceLocalCommand)+      modelOf AgentCmdRun inputs `shouldBe` Right (Just "claude-opus-4-8", SourceLocalCommand)++  describe "resolveAgentModelConfigFor (reasoning effort)" $ do+    it "defaults to unset effort when nothing is configured" $+      effortOf AgentCmdRun baseInputs `shouldBe` Right (Nothing, SourceBuiltinDefault)++    it "prefers a per-command effort over the shared default in the same scope" $ do+      let inputs =+            baseInputs+              { localConfig =+                  Map.fromList+                    [ (agentEffortConfigKey, "medium"),+                      (agentCommandEffortConfigKey AgentCmdRun, "max")+                    ]+              }+      effortOf AgentCmdRun inputs `shouldBe` Right (Just ThinkingMax, SourceLocalCommand)+      effortOf AgentCmdAssist inputs `shouldBe` Right (Just ThinkingMedium, SourceLocalDefault)++    it "lets a local default effort override a global per-command effort" $ do+      let inputs =+            baseInputs+              { localConfig = Map.fromList [(agentEffortConfigKey, "low")],+                globalConfig = Map.fromList [(agentCommandEffortConfigKey AgentCmdRun, "max")]+              }+      effortOf AgentCmdRun inputs `shouldBe` Right (Just ThinkingLow, SourceLocalDefault)++    it "keeps the effort environment variable above config" $ do+      let inputs =+            baseInputs+              { envEffort = Just "high",+                localConfig = Map.fromList [(agentCommandEffortConfigKey AgentCmdRun, "minimal")]+              }+      effortOf AgentCmdRun inputs `shouldBe` Right (Just ThinkingHigh, SourceEnv)++    it "prefers the subcommand effort flag over everything" $+      effortOf+        AgentCmdRun+        (baseInputs {cliEffort = Just "xhigh", cliEffortFromSubcommand = True, envEffort = Just "low"})+        `shouldBe` Right (Just ThinkingXHigh, SourceCliSubcommand)++    it "parses effort case-insensitively" $+      effortOf AgentCmdRun (baseInputs {cliEffort = Just "  MAX  "}) `shouldBe` Right (Just ThinkingMax, SourceCliParent)++    it "returns a diagnostic for an invalid effort value" $+      resolveAgentModelConfigFor AgentCmdRun (baseInputs {cliEffort = Just "ultra"}) `shouldSatisfy` \case+        Left err -> "Unknown reasoning effort" `Text.isInfixOf` err && "xhigh" `Text.isInfixOf` err+        Right _ -> False++providerOf :: AgentCommandName -> AgentConfigInputs -> Either Text (AgentProvider, AgentConfigSource)+providerOf c inputs =+  (\(p, _, _) -> (p.resolvedValue, p.resolvedSource)) <$> resolveAgentModelConfigFor c inputs++modelOf :: AgentCommandName -> AgentConfigInputs -> Either Text (Maybe Text, AgentConfigSource)+modelOf c inputs =+  (\(_, m, _) -> (m.resolvedValue, m.resolvedSource)) <$> resolveAgentModelConfigFor c inputs++effortOf :: AgentCommandName -> AgentConfigInputs -> Either Text (Maybe ThinkingLevel, AgentConfigSource)+effortOf c inputs =+  (\(_, _, e) -> (e.resolvedValue, e.resolvedSource)) <$> resolveAgentModelConfigFor c inputs++-- | Build an expected 'AgentModelConfig' with effort unset (the flat resolver+-- never sets effort).+cfg :: AgentProvider -> Maybe Text -> AgentModelConfig+cfg provider model =+  AgentModelConfig {agentProvider = provider, agentModel = model, agentEffort = Nothing}+ baseInputs :: AgentConfigInputs-baseInputs =-  AgentConfigInputs-    { cliProvider = Nothing,-      cliModel = Nothing,-      envProvider = Nothing,-      envModel = Nothing,-      localConfig = Map.empty,-      globalConfig = Map.empty-    }+baseInputs = baseAgentConfigInputs  config :: Text -> Text -> Map.Map Text Text config provider model =
test/Seihou/CLI/AgentLaunchSpec.hs view
@@ -150,7 +150,8 @@               baseModules = [],               files = [],               allowedTools = Nothing,-              tags = []+              tags = [],+              migrations = []             }     it "renders name, version, description as a three-line block" $       formatBlueprintIdentity (mk (Just "0.1") (Just "a thing"))
+ test/Seihou/CLI/AgentMigrateE2ESpec.hs view
@@ -0,0 +1,191 @@+module Seihou.CLI.AgentMigrateE2ESpec (tests) where++import Data.ByteString.Lazy qualified as LBS+import Data.Maybe (fromMaybe)+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Seihou.Core.Types (AppliedBlueprintMigration (..), Manifest (..))+import Seihou.Manifest.Types (manifestFromJSON)+import System.Directory+  ( createDirectoryIfMissing,+    doesFileExist,+    executable,+    getPermissions,+    setPermissions,+  )+import System.Environment (getEnvironment, getExecutablePath)+import System.Exit (ExitCode (..))+import System.FilePath (searchPathSeparator, takeDirectory, (</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Process (CreateProcess (..), proc, readCreateProcessWithExitCode)+import Test.Hspec+import Test.Tasty (TestTree)+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Agent migrate end-to-end" $ do+  it "exposes the required version window and rerun option in help" $ do+    binary <- seihouBinary+    (exitCode, output, _) <- runProcessText binary ["agent", "migrate", "--help"] Nothing Nothing+    exitCode `shouldBe` ExitSuccess+    output `shouldSatisfy` T.isInfixOf "Usage: seihou agent migrate BLUEPRINT --from VERSION --to VERSION [PROMPT]"+    output `shouldSatisfy` T.isInfixOf "--rerun"+    output `shouldNotSatisfy` T.isInfixOf "--no-baseline"+    output `shouldNotSatisfy` T.isInfixOf "--force"++  it "renders gap-tolerant pending prompts in order without writing a receipt" $+    withSystemTempDirectory "seihou-agent-migrate-debug" $ \root -> do+      binary <- seihouBinary+      let blueprintDir = root </> ".seihou" </> "modules" </> "payments"+          blueprintPath = blueprintDir </> "blueprint.dhall"+          manifestPath = root </> ".seihou" </> "manifest.json"+          xdgHome = root </> "xdg"+      createDirectoryIfMissing True blueprintDir+      createDirectoryIfMissing True xdgHome+      TIO.writeFile blueprintPath migrationBlueprintDhall+      inherited <- getEnvironment+      let overriddenNames = ["XDG_CONFIG_HOME", "SEIHOU_AGENT_PROVIDER", "SEIHOU_AGENT_MODEL", "SEIHOU_CONTEXT"]+          environment =+            ("XDG_CONFIG_HOME", xdgHome)+              : ("SEIHOU_AGENT_PROVIDER", "claude-cli")+              : filter (\(key, _) -> key `notElem` overriddenNames) inherited+      (exitCode, output, errorOutput) <-+        runProcessText+          binary+          [ "agent",+            "--debug",+            "migrate",+            "payments",+            "--from",+            "1.0.0",+            "--to",+            "3.0.0",+            "--var",+            "library.name=baikai"+          ]+          (Just root)+          (Just environment)+      case exitCode of+        ExitSuccess -> pure ()+        ExitFailure code ->+          expectationFailure $+            "debug migration exited "+              <> show code+              <> "\nstdout:\n"+              <> T.unpack output+              <> "\nstderr:\n"+              <> T.unpack errorOutput+      output `shouldSatisfy` T.isInfixOf "Blueprint migrations for payments: 1.0.0 -> 3.0.0"+      output `shouldSatisfy` T.isInfixOf "===== [1/2] 1.0.0 -> 2.0.0 ====="+      output `shouldSatisfy` T.isInfixOf "===== [2/2] 2.5.0 -> 3.0.0 ====="+      output `shouldSatisfy` T.isInfixOf "Shared upgrade guidance for baikai."+      output `shouldSatisfy` T.isInfixOf "Replace baikai legacy calls."+      let (_, afterFirst) = T.breakOn "1.0.0 -> 2.0.0" output+          (_, afterSecond) = T.breakOn "2.5.0 -> 3.0.0" afterFirst+      afterFirst `shouldNotBe` ""+      afterSecond `shouldNotBe` ""+      doesFileExist manifestPath `shouldReturn` False++  it "records successful edges and skips them on the next invocation" $+    withSystemTempDirectory "seihou-agent-migrate-receipts" $ \root -> do+      binary <- seihouBinary+      let blueprintDir = root </> ".seihou" </> "modules" </> "payments"+          blueprintPath = blueprintDir </> "blueprint.dhall"+          manifestPath = root </> ".seihou" </> "manifest.json"+          xdgHome = root </> "xdg"+          fakeBin = root </> "bin"+          fakeClaude = fakeBin </> "claude"+          launchLog = root </> "agent-launches.log"+      createDirectoryIfMissing True blueprintDir+      createDirectoryIfMissing True xdgHome+      createDirectoryIfMissing True fakeBin+      TIO.writeFile blueprintPath migrationBlueprintDhall+      TIO.writeFile fakeClaude "#!/bin/sh\nprintf 'called\\n' >> \"$SEIHOU_FAKE_AGENT_LOG\"\nexit 0\n"+      permissions <- getPermissions fakeClaude+      setPermissions fakeClaude (permissions {executable = True})++      inherited <- getEnvironment+      let inheritedPath = fromMaybe "" (lookup "PATH" inherited)+          overriddenNames = ["PATH", "XDG_CONFIG_HOME", "SEIHOU_AGENT_PROVIDER", "SEIHOU_AGENT_MODEL", "SEIHOU_CONTEXT", "SEIHOU_FAKE_AGENT_LOG"]+          environment =+            ("PATH", fakeBin <> [searchPathSeparator] <> inheritedPath)+              : ("XDG_CONFIG_HOME", xdgHome)+              : ("SEIHOU_AGENT_PROVIDER", "claude-cli")+              : ("SEIHOU_FAKE_AGENT_LOG", launchLog)+              : filter (\(key, _) -> key `notElem` overriddenNames) inherited+          args =+            [ "agent",+              "migrate",+              "payments",+              "--from",+              "1.0.0",+              "--to",+              "3.0.0",+              "--var",+              "library.name=baikai"+            ]++      (firstExit, _, firstError) <- runProcessText binary args (Just root) (Just environment)+      case firstExit of+        ExitSuccess -> pure ()+        ExitFailure code -> expectationFailure ("migration exited " <> show code <> "\nstderr:\n" <> T.unpack firstError)+      T.lines <$> TIO.readFile launchLog `shouldReturn` ["called", "called"]+      beforeResume <- LBS.readFile manifestPath+      manifest <-+        case manifestFromJSON beforeResume of+          Left err -> expectationFailure err >> fail "unreachable"+          Right decoded -> pure decoded+      map (\receipt -> (receipt.fromVersion, receipt.toVersion)) manifest.blueprintMigrations+        `shouldBe` [("1.0.0", "2.0.0"), ("2.5.0", "3.0.0")]++      (resumeExit, resumeOutput, resumeError) <- runProcessText binary args (Just root) (Just environment)+      case resumeExit of+        ExitSuccess -> pure ()+        ExitFailure code -> expectationFailure ("resume exited " <> show code <> "\nstderr:\n" <> T.unpack resumeError)+      resumeOutput `shouldSatisfy` T.isInfixOf "already have receipts"+      T.lines <$> TIO.readFile launchLog `shouldReturn` ["called", "called"]+      LBS.readFile manifestPath `shouldReturn` beforeResume++seihouBinary :: IO FilePath+seihouBinary = do+  testBinary <- getExecutablePath+  pure (takeDirectory (takeDirectory testBinary) </> "seihou" </> "seihou")++runProcessText ::+  FilePath ->+  [String] ->+  Maybe FilePath ->+  Maybe [(String, String)] ->+  IO (ExitCode, T.Text, T.Text)+runProcessText binary args workingDirectory environment = do+  let command = (proc binary args) {cwd = workingDirectory, env = environment}+  (exitCode, stdoutText, stderrText) <- readCreateProcessWithExitCode command ""+  pure (exitCode, T.pack stdoutText, T.pack stderrText)++migrationBlueprintDhall :: T.Text+migrationBlueprintDhall =+  T.unlines+    [ "{ name = \"payments\"",+      ", version = Some \"4.2.0\"",+      ", description = Some \"Payments library upgrade\"",+      ", prompt = \"Shared upgrade guidance for {{library.name}}.\"",+      ", vars =",+      "  [ { name = \"library.name\"",+      "    , type = \"text\"",+      "    , default = None Text",+      "    , description = None Text",+      "    , required = True",+      "    , validation = None Text",+      "    }",+      "  ]",+      ", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",+      ", baseModules = [] : List { module : Text, vars : List { name : Text, value : Text } }",+      ", files = [] : List { src : Text, description : Optional Text }",+      ", allowedTools = None (List Text)",+      ", tags = [] : List Text",+      ", migrations =",+      "  [ { from = \"2.5.0\", to = \"3.0.0\", prompt = \"Finish the baikai upgrade.\" }",+      "  , { from = \"1.0.0\", to = \"2.0.0\", prompt = \"Replace {{library.name}} legacy calls.\" }",+      "  ]",+      "}"+    ]
+ test/Seihou/CLI/AgentModelsSpec.hs view
@@ -0,0 +1,66 @@+module Seihou.CLI.AgentModelsSpec (tests) where++import Baikai.Model (Model)+import Baikai.Model qualified as BaikaiModel+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Seihou.CLI.AgentCompletion (AgentProvider (..))+import Seihou.CLI.AgentModels+import Test.Hspec+import Test.Tasty (TestTree)+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Seihou.CLI.AgentModels" $ do+  describe "availableAgentModels" $ do+    it "contains 31 unique Anthropic and OpenAI model IDs" $ do+      let ids = modelIds availableAgentModels+      length ids `shouldBe` 31+      Set.size (Set.fromList ids) `shouldBe` 31+      Set.fromList (map BaikaiModel.provider availableAgentModels)+        `shouldBe` Set.fromList ["anthropic", "openai"]++    it "contains the newest Claude and GPT-5.6 models" $+      modelIds availableAgentModels+        `shouldSatisfy` \ids ->+          all+            (`elem` ids)+            ["claude-sonnet-5", "gpt-5.6", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]++  describe "filterAgentModels" $ do+    it "maps the Anthropic API and Claude CLI providers to the same 9 models" $ do+      let anthropicIds = filteredIds AgentProviderAnthropic+          claudeCliIds = filteredIds AgentProviderClaudeCli+      anthropicIds `shouldBe` claudeCliIds+      length anthropicIds `shouldBe` 9++    it "maps the OpenAI API and Codex CLI providers to the same 22 models" $ do+      let openAIIds = filteredIds AgentProviderOpenAI+          codexCliIds = filteredIds AgentProviderCodexCli+      openAIIds `shouldBe` codexCliIds+      length openAIIds `shouldBe` 22++  describe "formatAgentModels" $ do+    it "renders model names, compatible providers, count, and alias guidance" $ do+      let output = formatAgentModels Nothing availableAgentModels+      output `shouldSatisfy` Text.isInfixOf "MODEL"+      output `shouldSatisfy` Text.isInfixOf "Claude Sonnet 5"+      output `shouldSatisfy` Text.isInfixOf "anthropic, claude-cli"+      output `shouldSatisfy` Text.isInfixOf "GPT-5.6 Terra"+      output `shouldSatisfy` Text.isInfixOf "openai, codex-cli"+      output `shouldSatisfy` Text.isInfixOf "31 models found."+      output `shouldSatisfy` Text.isInfixOf "aliases and custom model IDs remain accepted"++    it "reports the filtered model count" $ do+      formatAgentModels (Just AgentProviderAnthropic) availableAgentModels+        `shouldSatisfy` Text.isInfixOf "9 models found."+      formatAgentModels (Just AgentProviderCodexCli) availableAgentModels+        `shouldSatisfy` Text.isInfixOf "22 models found."++modelIds :: [Model] -> [Text]+modelIds = map BaikaiModel.modelId++filteredIds :: AgentProvider -> [Text]+filteredIds provider =+  modelIds (filterAgentModels (Just provider) availableAgentModels)
+ test/Seihou/CLI/AppliedBlueprintMigrationSpec.hs view
@@ -0,0 +1,80 @@+module Seihou.CLI.AppliedBlueprintMigrationSpec (tests) where++import Data.ByteString.Lazy qualified as LBS+import Data.Text qualified as T+import Data.Time (UTCTime, defaultTimeLocale, parseTimeOrError)+import Seihou.CLI.AppliedBlueprintMigration (recordAppliedBlueprintMigration)+import Seihou.Core.Types (AppliedBlueprintMigration (..), Manifest (..), ModuleName (..))+import Seihou.Manifest.Types (currentManifestVersion, manifestFromJSON)+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Seihou.CLI.AppliedBlueprintMigration" spec++fixedTime :: UTCTime+fixedTime =+  parseTimeOrError True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" "2026-07-20T12:00:00Z"++fixedTime2 :: UTCTime+fixedTime2 =+  parseTimeOrError True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" "2026-07-20T13:00:00Z"++mkReceipt :: T.Text -> T.Text -> T.Text -> UTCTime -> AppliedBlueprintMigration+mkReceipt blueprintName fromVersion toVersion appliedAt =+  AppliedBlueprintMigration+    { name = ModuleName blueprintName,+      blueprintVersion = Just "0.4.0",+      fromVersion = fromVersion,+      toVersion = toVersion,+      appliedAt = appliedAt,+      agentSessionId = Nothing+    }++readManifestFile :: FilePath -> IO Manifest+readManifestFile path = do+  bytes <- LBS.readFile path+  case manifestFromJSON bytes of+    Right manifest -> pure manifest+    Left err -> error ("test fixture: malformed manifest: " <> err)++spec :: Spec+spec = describe "recordAppliedBlueprintMigration" $ do+  it "creates a version-5 manifest for the first receipt" $+    withSystemTempDirectory "seihou-blueprint-migration" $ \dir -> do+      let manifestPath = dir </> ".seihou" </> "manifest.json"+          receipt = mkReceipt "payments" "1.0.0" "2.0.0" fixedTime+      result <- recordAppliedBlueprintMigration manifestPath receipt+      result `shouldBe` Right ()+      manifest <- readManifestFile manifestPath+      manifest.version `shouldBe` currentManifestVersion+      manifest.blueprintMigrations `shouldBe` [receipt]++  it "upserts the same exact edge and retains unrelated edges" $+    withSystemTempDirectory "seihou-blueprint-migration" $ \dir -> do+      let manifestPath = dir </> "manifest.json"+          first = mkReceipt "payments" "1.0.0" "2.0.0" fixedTime+          second = mkReceipt "payments" "2.5.0" "3.0.0" fixedTime+          replacement =+            (mkReceipt "payments" "1.0.0" "2.0.0" fixedTime2)+              { blueprintVersion = Just "0.5.0"+              }+      recordAppliedBlueprintMigration manifestPath first `shouldReturn` Right ()+      recordAppliedBlueprintMigration manifestPath second `shouldReturn` Right ()+      recordAppliedBlueprintMigration manifestPath replacement `shouldReturn` Right ()+      manifest <- readManifestFile manifestPath+      manifest.blueprintMigrations `shouldBe` [replacement, second]++  it "returns Left and preserves a corrupt existing manifest" $+    withSystemTempDirectory "seihou-blueprint-migration" $ \dir -> do+      let manifestPath = dir </> "manifest.json"+          corrupt = "{ this is not valid json"+      writeFile manifestPath corrupt+      result <- recordAppliedBlueprintMigration manifestPath (mkReceipt "payments" "1.0.0" "2.0.0" fixedTime)+      case result of+        Left err -> err `shouldSatisfy` not . T.null+        Right () -> expectationFailure "expected corrupt manifest failure"+      readFile manifestPath `shouldReturn` corrupt
+ test/Seihou/CLI/BlueprintMigrationSpec.hs view
@@ -0,0 +1,248 @@+module Seihou.CLI.BlueprintMigrationSpec (tests) where++import Data.IORef+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as T+import Data.Time (UTCTime)+import Seihou.CLI.AgentLaunch (AgentContext (..))+import Seihou.CLI.BlueprintExecution (PreparedBlueprintExecution (..))+import Seihou.CLI.BlueprintMigration+import Seihou.Core.Migration+import Seihou.Core.Types+import Seihou.Core.Version (Version, parseVersion)+import System.Exit (ExitCode (..))+import Test.Hspec+import Test.Tasty (TestTree)+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Seihou.CLI.BlueprintMigration" $ do+  describe "pendingBlueprintMigrations" $ do+    it "retains the planner's order across an intentional version gap" $ do+      let late = migration "2.5.0" "3.0.0"+          early = migration "1.0.0" "2.0.0"+          Right (Just migrationPlan) =+            planBlueprintMigrationChain+              "payments"+              [late, early]+              (version "1.0.0")+              (version "3.0.0")+      pendingBlueprintMigrations False blueprintName [] migrationPlan+        `shouldBe` [early, late]++    it "resumes by filtering only an already-recorded exact edge" $ do+      let migrationPlan = plan [first, second]+          receipts =+            [ receipt blueprintName "1.0.0" "2.0.0",+              receipt "another-blueprint" "2.0.0" "3.0.0"+            ]+      pendingBlueprintMigrations False blueprintName receipts migrationPlan+        `shouldBe` [second]++    it "keeps recorded edges when rerun is requested" $ do+      let migrationPlan = plan [first, second]+          receipts = [receipt blueprintName "1.0.0" "2.0.0"]+      pendingBlueprintMigrations True blueprintName receipts migrationPlan+        `shouldBe` [first, second]++  describe "renderBlueprintMigrationInstruction" $ do+    it "substitutes the variables resolved for the shared blueprint" $ do+      let declaration = VarDecl "library.name" VTText Nothing Nothing False Nothing+          resolved =+            Map.singleton+              "library.name"+              (ResolvedVar (VText "baikai") FromDefault declaration)+      renderBlueprintMigrationInstruction resolved (migrationWithPrompt "1" "2" "Upgrade {{library.name}}.")+        `shouldBe` "Upgrade baikai."++  describe "renderBlueprintMigrationSystemPrompt" $ do+    it "renders identity, position, shared guidance, edge instructions, and reference access" $ do+      let edge = migrationWithPrompt "1.0.0" "2.0.0" "Upgrade {{library.name}} now."+          rendered =+            renderBlueprintMigrationSystemPrompt+              "{{blueprint_name}} {{blueprint_version}} | {{migration_position}}/{{migration_total}} | {{migration_from}} -> {{migration_to}} | {{shared_prompt}} | {{migration_prompt}} | {{reference_files_dir}} | {{cwd}}"+              sampleContext+              samplePrepared+              1+              2+              edge+      rendered+        `shouldBe` "payments 4.2.0 | 1/2 | 1.0.0 -> 2.0.0 | Shared guidance for baikai. | Upgrade baikai now. | mounted at /tmp/payments/files | /tmp/project"++    it "delimits debug prompts in pending order without any execution callback" $ do+      let output =+            formatBlueprintMigrationDebugOutput+              (\position total edge -> "prompt " <> tshow position <> "/" <> tshow total <> " " <> edge.from)+              [first, second]+      output `shouldSatisfy` T.isInfixOf "===== [1/2] 1.0.0 -> 2.0.0 ====="+      output `shouldSatisfy` T.isInfixOf "===== [2/2] 2.0.0 -> 3.0.0 ====="+      T.breakOn "2.0.0 -> 3.0.0" output `shouldSatisfy` (not . T.null . snd)++  describe "runBlueprintMigrationsWith" $ do+    it "reports no work without invoking either callback" $ do+      calls <- newIORef ([] :: [Text])+      result <-+        runBlueprintMigrationsWith+          (\_ _ _ -> modifyIORef' calls (<> ["launch"]) >> pure (Right ()))+          (\_ -> modifyIORef' calls (<> ["record"]) >> pure (Right ()))+          []+      result `shouldBe` BlueprintMigrationNoWork+      readIORef calls `shouldReturn` []++    it "launches and records every edge sequentially" $ do+      calls <- newIORef ([] :: [Text])+      let launch position total edge = do+            modifyIORef' calls (<> ["launch " <> tshow position <> "/" <> tshow total <> " " <> edge.from])+            pure (Right ())+          record edge = do+            modifyIORef' calls (<> ["record " <> edge.from])+            pure (Right ())+      result <- runBlueprintMigrationsWith launch record [first, second]+      result `shouldBe` BlueprintMigrationComplete [first, second]+      readIORef calls+        `shouldReturn` [ "launch 1/2 1.0.0",+                         "record 1.0.0",+                         "launch 2/2 2.0.0",+                         "record 2.0.0"+                       ]++    it "records only completed edges after failure and resumes at the failed edge" $ do+      calls <- newIORef ([] :: [Text])+      recorded <- newIORef ([] :: [AppliedBlueprintMigration])+      let third = migration "3.0.0" "4.0.0"+          migrationPlan =+            BlueprintMigrationPlan+              { blueprintPlanName = "payments",+                blueprintPlanFrom = version "1.0.0",+                blueprintPlanTo = version "4.0.0",+                blueprintPlanSteps = [first, second, third]+              }+          launch _ _ edge = do+            modifyIORef' calls (<> ["launch " <> edge.from])+            pure $+              if edge == second+                then Left (BlueprintMigrationProcessFailure (ExitFailure 17))+                else Right ()+          record edge = do+            modifyIORef' calls (<> ["record " <> edge.from])+            modifyIORef' recorded (<> [receipt blueprintName edge.from edge.to])+            pure (Right ())+      result <- runBlueprintMigrationsWith launch record [first, second, third]+      result+        `shouldBe` BlueprintMigrationLaunchFailed second (BlueprintMigrationProcessFailure (ExitFailure 17))+      readIORef calls+        `shouldReturn` ["launch 1.0.0", "record 1.0.0", "launch 2.0.0"]++      savedReceipts <- readIORef recorded+      let resumed = pendingBlueprintMigrations False blueprintName savedReceipts migrationPlan+      resumed `shouldBe` [second, third]++      resumedResult <-+        runBlueprintMigrationsWith+          (\_ _ edge -> modifyIORef' calls (<> ["resume " <> edge.from]) >> pure (Right ()))+          record+          resumed+      resumedResult `shouldBe` BlueprintMigrationComplete [second, third]+      readIORef recorded `shouldReturn` map (\edge -> receipt blueprintName edge.from edge.to) [first, second, third]++    it "stops before the next launch when receipt recording fails" $ do+      calls <- newIORef ([] :: [Text])+      let launch _ _ edge = modifyIORef' calls (<> ["launch " <> edge.from]) >> pure (Right ())+          record edge = modifyIORef' calls (<> ["record " <> edge.from]) >> pure (Left "disk full")+      result <- runBlueprintMigrationsWith launch record [first, second]+      result `shouldBe` BlueprintMigrationRecordFailed first "disk full"+      readIORef calls `shouldReturn` ["launch 1.0.0", "record 1.0.0"]++blueprintName :: ModuleName+blueprintName = "payments"++first :: BlueprintMigration+first = migration "1.0.0" "2.0.0"++second :: BlueprintMigration+second = migration "2.0.0" "3.0.0"++migration :: Text -> Text -> BlueprintMigration+migration fromVersion toVersion =+  migrationWithPrompt fromVersion toVersion ("Migrate from " <> fromVersion <> " to " <> toVersion)++migrationWithPrompt :: Text -> Text -> Text -> BlueprintMigration+migrationWithPrompt fromVersion toVersion instructions =+  BlueprintMigration+    { from = fromVersion,+      to = toVersion,+      prompt = instructions+    }++plan :: [BlueprintMigration] -> BlueprintMigrationPlan+plan steps =+  BlueprintMigrationPlan+    { blueprintPlanName = "payments",+      blueprintPlanFrom = version "1.0.0",+      blueprintPlanTo = version "3.0.0",+      blueprintPlanSteps = steps+    }++receipt :: ModuleName -> Text -> Text -> AppliedBlueprintMigration+receipt name fromVersion toVersion =+  AppliedBlueprintMigration+    { name,+      blueprintVersion = Just "4.2.0",+      fromVersion,+      toVersion,+      appliedAt = read "2026-07-20 12:00:00 UTC" :: UTCTime,+      agentSessionId = Nothing+    }++version :: Text -> Version+version raw =+  case parseVersion raw of+    Just parsed -> parsed+    Nothing -> error "test version should parse"++tshow :: (Show a) => a -> Text+tshow = T.pack . show++sampleContext :: AgentContext+sampleContext =+  AgentContext+    { cwd = "/tmp/project",+      seihouInitialized = True,+      hasManifest = False,+      localModuleDhall = False,+      localModules = [],+      availableModules = []+    }++samplePrepared :: PreparedBlueprintExecution+samplePrepared =+  let declaration = VarDecl "library.name" VTText Nothing Nothing False Nothing+      resolved =+        Map.singleton+          "library.name"+          (ResolvedVar (VText "baikai") FromDefault declaration)+      blueprint =+        Blueprint+          { name = blueprintName,+            version = Just "4.2.0",+            description = Just "Payments upgrade",+            prompt = "Shared guidance for {{library.name}}.",+            vars = [declaration],+            prompts = [],+            baseModules = [],+            files = [],+            allowedTools = Nothing,+            tags = [],+            migrations = [first, second]+          }+   in PreparedBlueprintExecution+        { preparedBlueprint = blueprint,+          preparedBlueprintDir = "/tmp/payments",+          preparedResolvedVariables = resolved,+          preparedMountedFilesDir = Just "/tmp/payments/files",+          preparedReferenceFiles = "  - guide.md",+          preparedReferenceFilesAccess = "mounted at /tmp/payments/files",+          preparedSharedPrompt = "Shared guidance for baikai.",+          preparedAllowedTools = ["Read"]+        }
+ test/Seihou/CLI/CommandExecutionSpec.hs view
@@ -0,0 +1,158 @@+module Seihou.CLI.CommandExecutionSpec (tests) where++import Data.Map.Strict qualified as Map+import Data.Maybe (fromJust)+import Data.Time (UTCTime, defaultTimeLocale, parseTimeOrError)+import Effectful (runPureEff)+import Seihou.CLI.CommandExecution+import Seihou.Core.CommandFingerprint (fingerprintCommand)+import Seihou.Core.Types+import Seihou.Effect.ProcessPure (ProcessMock (..), runProcessPure)+import Seihou.Prelude+import System.Exit (ExitCode (..))+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Seihou.CLI.CommandExecution" spec++fixedTime :: UTCTime+fixedTime = parseTimeOrError True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" "2026-07-19T18:00:00Z"++laterTime :: UTCTime+laterTime = parseTimeOrError True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" "2026-07-19T19:00:00Z"++commandOp :: Text -> Maybe FilePath -> ModuleName -> Int -> Operation+commandOp command workDir moduleName occurrence =+  RunCommandOp {command, workDir, moduleName, occurrence}++fingerprintOf :: Operation -> CommandFingerprint+fingerprintOf = fromJust . fingerprintCommand++receiptFor :: UTCTime -> Operation -> CommandReceipt+receiptFor completedAt operation@RunCommandOp {command, workDir, moduleName} =+  CommandReceipt+    { fingerprint = fingerprintOf operation,+      moduleName,+      command,+      workDir,+      completedAt+    }+receiptFor _ _ = error "receiptFor requires RunCommandOp"++successMock :: Text -> ProcessMock+successMock command =+  ProcessMock+    { mockCommand = "sh",+      mockArgs = ["-c", command],+      mockResult = (ExitSuccess, "output", "")+    }++spec :: Spec+spec = do+  describe "planCommands" $ do+    it "preserves command order and ignores non-command operations" $ do+      let first = commandOp "echo first" Nothing "app" 0+          second = commandOp "echo second" Nothing "app" 0+          plan = planCommands RunAllCommands Map.empty [first, WriteFileOp "file" "content" Template, second]+      map (.operation) plan.commands `shouldBe` [first, second]+      map (.disposition) plan.commands `shouldBe` [CommandWillRun, CommandWillRun]++    it "skips only fingerprints with successful prior receipts in changed-only mode" $ do+      let unchanged = commandOp "echo same" Nothing "app" 0+          changed = commandOp "echo changed" Nothing "app" 0+          prior = Map.singleton (fingerprintOf unchanged) (receiptFor fixedTime unchanged)+          plan = planCommands RunChangedCommands prior [unchanged, changed]+      map (.disposition) plan.commands+        `shouldBe` [CommandSkippedUnchanged, CommandWillRun]+      summarizeCommandPlan plan+        `shouldBe` CommandPlanSummary {willRun = 1, skippedUnchanged = 1, skippedDisabled = 0}++    it "runs duplicate declarations independently because occurrences differ" $ do+      let first = commandOp "echo same" Nothing "app" 0+          second = commandOp "echo same" Nothing "app" 1+          prior = Map.singleton (fingerprintOf first) (receiptFor fixedTime first)+          plan = planCommands RunChangedCommands prior [first, second]+      map (.disposition) plan.commands+        `shouldBe` [CommandSkippedUnchanged, CommandWillRun]++    it "marks every command disabled without minting receipts" $ do+      let operations = [commandOp "echo one" Nothing "app" 0, commandOp "echo two" Nothing "app" 0]+          plan = planCommands DisableCommands Map.empty operations+      map (.disposition) plan.commands+        `shouldBe` [CommandSkippedDisabled, CommandSkippedDisabled]++  describe "executeCommandPlan" $ do+    it "executes runnable commands in order and returns successful receipts" $ do+      let first = commandOp "echo first" Nothing "app" 0+          second = commandOp "echo second" (Just "subdir") "app" 0+          plan = planCommands RunAllCommands Map.empty [first, second]+          result =+            runPureEff $+              runProcessPure [successMock "echo first", successMock "echo second"] $+                executeCommandPlan laterTime plan+      case result of+        Right receipts -> do+          map (.fingerprint) receipts `shouldBe` map fingerprintOf [first, second]+          map (.completedAt) receipts `shouldBe` [laterTime, laterTime]+        Left err -> expectationFailure ("Expected success, got: " <> show err)++    it "does not execute skipped commands" $ do+      let operation = commandOp "echo skipped" Nothing "app" 0+          priorReceipt = receiptFor fixedTime operation+          prior = Map.singleton priorReceipt.fingerprint priorReceipt+          plan = planCommands RunChangedCommands prior [operation]+          result = runPureEff $ runProcessPure [] $ executeCommandPlan laterTime plan+      result `shouldBe` Right []++    it "returns a structured error at the first failure" $ do+      let failing = commandOp "exit 7" Nothing "app" 0+          neverReached = commandOp "echo later" Nothing "app" 0+          plan = planCommands RunAllCommands Map.empty [failing, neverReached]+          mocks =+            [ ProcessMock+                { mockCommand = "sh",+                  mockArgs = ["-c", "exit 7"],+                  mockResult = (ExitFailure 7, "partial", "boom")+                },+              successMock "echo later"+            ]+          result = runPureEff $ runProcessPure mocks $ executeCommandPlan laterTime plan+      case result of+        Left err -> do+          err.exitCode `shouldBe` 7+          err.stdout `shouldBe` "partial"+          err.stderr `shouldBe` "boom"+          err.command.operation `shouldBe` failing+        Right receipts -> expectationFailure ("Expected failure, got receipts: " <> show receipts)++  describe "finalizeCommandReceipts" $ do+    it "keeps fresh successes and unchanged receipts while dropping removed commands" $ do+      let unchanged = commandOp "echo unchanged" Nothing "app" 0+          changed = commandOp "echo changed" Nothing "app" 0+          removed = commandOp "echo removed" Nothing "app" 0+          oldUnchanged = receiptFor fixedTime unchanged+          oldRemoved = receiptFor fixedTime removed+          newChanged = receiptFor laterTime changed+          prior =+            Map.fromList+              [ (oldUnchanged.fingerprint, oldUnchanged),+                (oldRemoved.fingerprint, oldRemoved)+              ]+          plan = planCommands RunChangedCommands prior [unchanged, changed]+          finalized = finalizeCommandReceipts plan [newChanged] prior+      finalized+        `shouldBe` Map.fromList+          [ (oldUnchanged.fingerprint, oldUnchanged),+            (newChanged.fingerprint, newChanged)+          ]++    it "retains only matching old receipts when commands are disabled" $ do+      let old = commandOp "echo old" Nothing "app" 0+          new = commandOp "echo new" Nothing "app" 0+          oldReceipt = receiptFor fixedTime old+          prior = Map.singleton oldReceipt.fingerprint oldReceipt+          plan = planCommands DisableCommands prior [old, new]+      finalizeCommandReceipts plan [] prior+        `shouldBe` Map.singleton oldReceipt.fingerprint oldReceipt
test/Seihou/CLI/MigrateSpec.hs view
@@ -129,7 +129,9 @@                 { hash = hashContent content,                   moduleName = modName,                   strategy = Template,-                  generatedAt = fixedTime+                  generatedAt = fixedTime,+                  baseline = Nothing,+                  applicationIds = mempty                 }             )           | (path, content) <- entries@@ -269,7 +271,9 @@                 { hash = hashContent content,                   moduleName = ModuleName fix.modName,                   strategy = Template,-                  generatedAt = fixedTime+                  generatedAt = fixedTime,+                  baseline = Nothing,+                  applicationIds = mempty                 }             )           | (path, content) <- entries
test/Seihou/CLI/StatusSpec.hs view
@@ -15,10 +15,16 @@     MigrationPlan (..),   ) import Seihou.Core.Types-  ( AppliedBlueprint (..),+  ( ApplicationId (..),+    AppliedBlueprint (..),+    AppliedBlueprintMigration (..),+    AppliedComposition (..),+    AppliedInstanceState (..),     AppliedModule (..),+    AppliedTarget (..),     Manifest (..),     ModuleName (..),+    RecipeName (..),     emptyParentVars,   ) import Seihou.Core.Version qualified@@ -56,6 +62,30 @@       files = Map.empty     } +mkApplication :: Text -> [Text] -> AppliedComposition+mkApplication target modules =+  AppliedComposition+    { applicationId = ApplicationId ("app-" <> target),+      target = AppliedRecipeTarget (RecipeName target),+      targetSource = "/installed/" <> T.unpack target,+      targetVersion = Just "1.0.0",+      additionalModules = [],+      namespace = Nothing,+      context = Nothing,+      instances = map mkInstance modules,+      commandReceipts = Map.empty,+      appliedAt = fixedTime+    }+  where+    mkInstance name =+      AppliedInstanceState+        { name = ModuleName name,+          parentVars = emptyParentVars,+          source = "/installed/" <> T.unpack name,+          moduleVersion = Just "1.0.0",+          resolvedVars = Map.empty+        }+ -- | Build a 'MigrationPlan' fixture for use with formatStatus. mkPlan :: Text -> Text -> Text -> Int -> MigrationPlan mkPlan modName from to nSteps =@@ -101,6 +131,17 @@ withManifestBlueprint :: Maybe AppliedBlueprint -> Manifest -> Manifest withManifestBlueprint mb m = m {blueprint = mb} +mkBlueprintMigrationReceipt :: Text -> Maybe Text -> Text -> Text -> AppliedBlueprintMigration+mkBlueprintMigrationReceipt blueprintName artifactVersion fromVersion toVersion =+  AppliedBlueprintMigration+    { name = ModuleName blueprintName,+      blueprintVersion = artifactVersion,+      fromVersion = fromVersion,+      toVersion = toVersion,+      appliedAt = fixedTime,+      agentSessionId = Nothing+    }+ spec :: Spec spec = describe "formatStatus" $ do   describe "blueprint provenance" $ do@@ -165,6 +206,19 @@       out `shouldSatisfy` T.isInfixOf "Blueprint: pure-prompt (applied"       out `shouldSatisfy` T.isInfixOf "  Baseline: (none declared)" +  describe "blueprint migration receipts" $ do+    it "omits the section for an empty ledger" $ do+      let out = formatStatus False (mkManifest []) [] Nothing []+      out `shouldNotSatisfy` T.isInfixOf "Blueprint migrations:"++    it "renders blueprint name, artifact version, exact edge, and timestamp" $ do+      let receipt = mkBlueprintMigrationReceipt "payments" (Just "0.4.0") "1.0.0" "2.0.0"+          manifest = (mkManifest []) {blueprintMigrations = [receipt]}+          out = formatStatus False manifest [] Nothing []+      out `shouldSatisfy` T.isInfixOf "Blueprint migrations:"+      out `shouldSatisfy` T.isInfixOf "payments v0.4.0: 1.0.0 -> 2.0.0"+      out `shouldSatisfy` T.isInfixOf "2026-04-15 10:00 UTC"+   it "all modules clean: no remediation, no Recommended actions block" $ do     let am = mkApplied "demo" (Just "1.0.0")         manifest = mkManifest [am]@@ -176,29 +230,29 @@     out `shouldNotSatisfy` T.isInfixOf "Recommended actions"     out `shouldNotSatisfy` T.isInfixOf "Pending migration" -  it "outdated only (no migration declared): per-row upgrade hint and summary entry" $ do+  it "outdated only recommends the project-aware update workflow" $ do     let am = mkApplied "demo" (Just "0.1.0")         manifest = mkManifest [am]         entries = Just [mkEntry "demo" (Just "0.1.0") (Just "0.3.0") OutdatedSt]         out = formatStatus False manifest [] entries []     out `shouldSatisfy` T.isInfixOf "outdated: 0.3.0 available"-    out `shouldSatisfy` T.isInfixOf "Run: seihou upgrade demo"+    out `shouldSatisfy` T.isInfixOf "Run: seihou update demo"     out `shouldSatisfy` T.isInfixOf "Recommended actions:"-    out `shouldSatisfy` T.isInfixOf "  seihou upgrade demo"+    out `shouldSatisfy` T.isInfixOf "  seihou update demo"     out `shouldNotSatisfy` T.isInfixOf "Pending migration" -  it "pending migration (full chain): per-row migrate hint and summary" $ do+  it "pending migration keeps detail and recommends update" $ do     let am = mkApplied "demo" (Just "1.0.0")         manifest = mkManifest [am]         plan = mkPlan "demo" "1.0.0" "2.0.0" 1         out = formatStatus False manifest [] Nothing [(ModuleName "demo", plan)]     out `shouldSatisfy` T.isInfixOf "Pending migration: 1.0.0 -> 2.0.0 (1 step(s))"-    out `shouldSatisfy` T.isInfixOf "Run: seihou migrate demo"+    out `shouldSatisfy` T.isInfixOf "Run: seihou update demo"     out `shouldSatisfy` T.isInfixOf "Recommended actions:"-    out `shouldSatisfy` T.isInfixOf "  seihou migrate demo"+    out `shouldSatisfy` T.isInfixOf "  seihou update demo"     out `shouldNotSatisfy` T.isInfixOf "seihou upgrade" -  it "outdated + pending migration: single migrate hint, no upgrade hint" $ do+  it "outdated plus pending migration produces one update hint" $ do     let am = mkApplied "demo" (Just "0.1.0")         manifest = mkManifest [am]         plan = mkPlan "demo" "0.1.0" "0.3.0" 6@@ -206,15 +260,15 @@         out = formatStatus False manifest [] entries [(ModuleName "demo", plan)]     out `shouldSatisfy` T.isInfixOf "outdated: 0.3.0 available"     out `shouldSatisfy` T.isInfixOf "Pending migration: 0.1.0 -> 0.3.0 (6 step(s))"-    out `shouldSatisfy` T.isInfixOf "Run: seihou migrate demo"+    out `shouldSatisfy` T.isInfixOf "Run: seihou update demo"     out `shouldNotSatisfy` T.isInfixOf "seihou upgrade demo"     out `shouldSatisfy` T.isInfixOf "Recommended actions:"-    out `shouldSatisfy` T.isInfixOf "  seihou migrate demo"+    out `shouldSatisfy` T.isInfixOf "  seihou update demo"    -- Master-plan live-tree fixture: manifest=0.1.0, installed=0.3.0,   -- declared [0.1.0 → 0.2.0]. The chain reaches 0.2 via ops; the   -- supplied target is 0.3, so planTo = 0.3. Status surfaces the-  -- single in-window step and points at `seihou migrate demo` as the+  -- single in-window step and points at `seihou update demo` as the   -- remediation.   it "partial-cover plan: chain reaches an intermediate version, target is the user's installed copy" $ do     let am = mkApplied "demo" (Just "0.1.0")@@ -222,9 +276,9 @@         plan = mkPlan "demo" "0.1.0" "0.3.0" 1         out = formatStatus False manifest [] Nothing [(ModuleName "demo", plan)]     out `shouldSatisfy` T.isInfixOf "Pending migration: 0.1.0 -> 0.3.0 (1 step(s))"-    out `shouldSatisfy` T.isInfixOf "Run: seihou migrate demo"+    out `shouldSatisfy` T.isInfixOf "Run: seihou update demo"     out `shouldSatisfy` T.isInfixOf "Recommended actions:"-    out `shouldSatisfy` T.isInfixOf "  seihou migrate demo"+    out `shouldSatisfy` T.isInfixOf "  seihou update demo"     -- Doomed vocabulary is gone from status output.     out `shouldNotSatisfy` T.isInfixOf "Blocked"     out `shouldNotSatisfy` T.isInfixOf "no migration declared from"@@ -246,10 +300,49 @@             }         out = formatStatus False manifest [] Nothing [(ModuleName "demo", plan)]     out `shouldSatisfy` T.isInfixOf "Pending migration: 0.2.0 -> 0.3.0 (0 step(s))"-    out `shouldSatisfy` T.isInfixOf "Run: seihou migrate demo"+    out `shouldSatisfy` T.isInfixOf "Run: seihou update demo"     out `shouldSatisfy` T.isInfixOf "Recommended actions:"-    out `shouldSatisfy` T.isInfixOf "  seihou migrate demo"+    out `shouldSatisfy` T.isInfixOf "  seihou update demo"     -- The doomed vocabulary stays out of the rendered status.     out `shouldNotSatisfy` T.isInfixOf "Blocked"     out `shouldNotSatisfy` T.isInfixOf "--bump-only"     out `shouldNotSatisfy` T.isInfixOf "[blocked]"++  it "deduplicates repeated instances into one recipe application action" $ do+    let duplicate = mkApplied "demo" (Just "1.0.0")+        manifest =+          (mkManifest [duplicate, duplicate])+            { applications = [mkApplication "stack" ["demo", "demo"]]+            }+        plan = mkPlan "demo" "1.0.0" "2.0.0" 1+        out = formatStatus False manifest [] Nothing [(ModuleName "demo", plan)]+        recommendationLines = filter (== "  seihou update stack") (T.lines out)+    recommendationLines `shouldBe` ["  seihou update stack"]+    T.count "seihou update stack" out `shouldBe` 1+    out `shouldNotSatisfy` T.isInfixOf "  seihou migrate demo"++  it "recommends each affected application plus the whole-project update" $ do+    let shared = mkApplied "shared" (Just "1.0.0")+        manifest =+          (mkManifest [shared])+            { applications =+                [ mkApplication "stack-one" ["shared"],+                  mkApplication "stack-two" ["shared"]+                ]+            }+        plan = mkPlan "shared" "1.0.0" "2.0.0" 1+        out = formatStatus False manifest [] Nothing [(ModuleName "shared", plan)]+        recommendationLines = dropWhile (/= "Recommended actions:") (T.lines out)+    recommendationLines+      `shouldBe` [ "Recommended actions:",+                   "  seihou update stack-one",+                   "  seihou update stack-two",+                   "  seihou update"+                 ]++  it "deduplicates legacy instances by bare module name" $ do+    let duplicate = mkApplied "demo" (Just "1.0.0")+        manifest = mkManifest [duplicate, duplicate]+        plan = mkPlan "demo" "1.0.0" "2.0.0" 1+        out = formatStatus False manifest [] Nothing [(ModuleName "demo", plan)]+    T.count "seihou update demo" out `shouldBe` 2
+ test/Seihou/CLI/UpdateE2ESpec.hs view
@@ -0,0 +1,182 @@+module Seihou.CLI.UpdateE2ESpec (tests) where++import Data.ByteString.Lazy qualified as LBS+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Seihou.CLI.UpdateSpec (UpdateFixture (..), prepareUpdateFixture)+import System.Directory (doesFileExist)+import System.Environment (getEnvironment, getExecutablePath)+import System.Exit (ExitCode (..))+import System.FilePath (takeDirectory, (</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Process (CreateProcess (..), callProcess, proc, readCreateProcessWithExitCode, readProcess)+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Update end-to-end" spec++spec :: Spec+spec = do+  it "updates through the executable and preserves a non-overlapping user edit" $+    withSystemTempDirectory "seihou-update-executable" $ \root -> do+      fixture <- prepareUpdateFixture root+      binary <- seihouBinary+      TIO.writeFile fixture.projectFile "hello accepted by-user\nkeep\nv1\n"+      (exitCode, stdoutText, stderrText) <- runSeihou binary fixture ["update", "demo", "--json"]+      case exitCode of+        ExitSuccess -> pure ()+        ExitFailure code -> expectationFailure ("update exited " <> show code <> "\nstdout:\n" <> T.unpack stdoutText <> "\nstderr:\n" <> T.unpack stderrText)+      stdoutText `shouldSatisfy` T.isInfixOf "\"outcome\":\"applied\""+      stdoutText `shouldSatisfy` T.isInfixOf "\"skippedUnchanged\":1"+      stdoutText `shouldNotSatisfy` T.isInfixOf "Apply?"+      stderrText `shouldNotSatisfy` T.isInfixOf "Apply?"+      TIO.readFile fixture.projectFile `shouldReturn` "hello accepted by-user\nkeep\nv2\n"+      TIO.readFile (fixture.installedModule <> "/module.dhall")+        `shouldReturnSatisfy` T.isInfixOf "Some \"2.0.0\""+      doesFileExist (fixture.projectRoot <> "/command.log") `shouldReturn` False+      (statusExit, statusOut, _) <- runSeihou binary fixture ["status"]+      statusExit `shouldBe` ExitSuccess+      statusOut `shouldNotSatisfy` T.isInfixOf "seihou update demo"+      afterApply <- LBS.readFile fixture.manifestPath+      (noOpExit, noOpOut, noOpErr) <- runSeihou binary fixture ["update", "demo", "--json"]+      noOpExit `shouldBe` ExitSuccess+      noOpOut `shouldSatisfy` T.isInfixOf "\"alreadyUpToDate\":true"+      noOpOut `shouldSatisfy` T.isInfixOf "\"outcome\":\"plan\""+      noOpErr `shouldNotSatisfy` T.isInfixOf "Apply?"+      LBS.readFile fixture.manifestPath `shouldReturn` afterApply++  it "keeps project, manifest, and installed cache byte-identical for JSON dry-run" $+    withSystemTempDirectory "seihou-update-executable-dry" $ \root -> do+      fixture <- prepareUpdateFixture root+      binary <- seihouBinary+      beforeProject <- TIO.readFile fixture.projectFile+      beforeManifest <- LBS.readFile fixture.manifestPath+      beforeInstalled <- TIO.readFile (fixture.installedModule <> "/module.dhall")+      (exitCode, stdoutText, _) <- runSeihou binary fixture ["update", "--dry-run", "--json"]+      exitCode `shouldBe` ExitSuccess+      stdoutText `shouldSatisfy` T.isInfixOf "\"outcome\":\"plan\""+      TIO.readFile fixture.projectFile `shouldReturn` beforeProject+      LBS.readFile fixture.manifestPath `shouldReturn` beforeManifest+      TIO.readFile (fixture.installedModule <> "/module.dhall") `shouldReturn` beforeInstalled++  it "commits exactly the managed update paths with a requested Conventional Commit message" $+    withSystemTempDirectory "seihou-update-executable-commit" $ \root -> do+      fixture <- prepareUpdateFixture root+      binary <- seihouBinary+      callProcess "git" ["-C", fixture.projectRoot, "init", "-q"]+      callProcess "git" ["-C", fixture.projectRoot, "config", "user.name", "Seihou Test"]+      callProcess "git" ["-C", fixture.projectRoot, "config", "user.email", "test@example.com"]+      callProcess "git" ["-C", fixture.projectRoot, "add", "."]+      callProcess "git" ["-C", fixture.projectRoot, "commit", "-qm", "test: record v1 fixture"]+      (exitCode, stdoutText, stderrText) <-+        runSeihou+          binary+          fixture+          ["update", "demo", "--json", "--commit-message", "chore(seihou): update demo"]+      case exitCode of+        ExitSuccess -> pure ()+        ExitFailure code -> expectationFailure ("update exited " <> show code <> "\nstdout:\n" <> T.unpack stdoutText <> "\nstderr:\n" <> T.unpack stderrText)+      subject <- T.strip . T.pack <$> readProcess "git" ["-C", fixture.projectRoot, "log", "-1", "--pretty=%s"] ""+      subject `shouldBe` "chore(seihou): update demo"+      worktree <- T.strip . T.pack <$> readProcess "git" ["-C", fixture.projectRoot, "status", "--porcelain"] ""+      worktree `shouldBe` ""++  it "retains an edited orphan under --force" $+    withSystemTempDirectory "seihou-update-executable-orphan" $ \root -> do+      fixture <- prepareUpdateFixture root+      binary <- seihouBinary+      let modulePath = fixture.remote <> "/module.dhall"+      moduleBody <- TIO.readFile modulePath+      TIO.writeFile+        modulePath+        ( T.replace+            ", steps = [{ strategy = \"template\", src = \"README.tmpl\", dest = \"README.md\", when = None Text, patch = None Text }]"+            ", steps = [] : List { strategy : Text, src : Text, dest : Text, when : Optional Text, patch : Optional Text }"+            moduleBody+        )+      callProcess "git" ["-C", fixture.remote, "add", "module.dhall"]+      callProcess "git" ["-C", fixture.remote, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "commit", "-qm", "remove generated file"]+      TIO.writeFile fixture.projectFile "user-owned orphan\n"+      (exitCode, stdoutText, stderrText) <- runSeihou binary fixture ["update", "demo", "--force", "--json"]+      case exitCode of+        ExitSuccess -> pure ()+        ExitFailure code -> expectationFailure ("update exited " <> show code <> "\nstdout:\n" <> T.unpack stdoutText <> "\nstderr:\n" <> T.unpack stderrText)+      stdoutText `shouldSatisfy` T.isInfixOf "\"editedOrphans\":1"+      TIO.readFile fixture.projectFile `shouldReturn` "user-owned orphan\n"+      TIO.readFile fixture.manifestPath `shouldReturnSatisfy` T.isInfixOf "README.md"++  it "reports an unresolved overlap before publishing project, manifest, or cache state" $+    withSystemTempDirectory "seihou-update-executable-conflict" $ \root -> do+      fixture <- prepareUpdateFixture root+      binary <- seihouBinary+      TIO.writeFile (fixture.remote <> "/files/README.tmpl") "candidate {{project.name}}\nkeep\nv2\n"+      callProcess "git" ["-C", fixture.remote, "add", "files/README.tmpl"]+      callProcess "git" ["-C", fixture.remote, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "commit", "-qm", "overlap"]+      TIO.writeFile fixture.projectFile "user accepted\nkeep\nv1\n"+      beforeManifest <- LBS.readFile fixture.manifestPath+      beforeInstalled <- LBS.readFile (fixture.installedModule <> "/module.dhall")+      (exitCode, stdoutText, _) <- runSeihou binary fixture ["update", "demo", "--json"]+      exitCode `shouldSatisfy` (/= ExitSuccess)+      stdoutText `shouldSatisfy` T.isInfixOf "\"code\":\"unresolved_conflicts\""+      TIO.readFile fixture.projectFile `shouldReturn` "user accepted\nkeep\nv1\n"+      LBS.readFile fixture.manifestPath `shouldReturn` beforeManifest+      LBS.readFile (fixture.installedModule <> "/module.dhall") `shouldReturn` beforeInstalled+      (forceExit, forceOut, forceErr) <- runSeihou binary fixture ["update", "demo", "--force", "--json"]+      case forceExit of+        ExitSuccess -> pure ()+        ExitFailure code -> expectationFailure ("forced update exited " <> show code <> "\nstdout:\n" <> T.unpack forceOut <> "\nstderr:\n" <> T.unpack forceErr)+      forceOut `shouldSatisfy` T.isInfixOf "\"outcome\":\"applied\""+      TIO.readFile fixture.projectFile `shouldReturn` "candidate accepted\nkeep\nv2\n"++  it "exposes update and its options through the shared Bash, Zsh, and Fish completion protocol" $ do+    binary <- seihouBinary+    (topExit, topOutput, _) <- runProcessText binary ["--bash-completion-enriched", "--bash-completion-index", "0"] Nothing Nothing+    topExit `shouldBe` ExitSuccess+    topOutput `shouldSatisfy` T.isInfixOf "update\tUpdate recorded project applications"+    (optionExit, optionOutput, _) <-+      runProcessText+        binary+        [ "--bash-completion-enriched",+          "--bash-completion-index",+          "2",+          "--bash-completion-word",+          "seihou",+          "--bash-completion-word",+          "update"+        ]+        Nothing+        Nothing+    optionExit `shouldBe` ExitSuccess+    optionOutput `shouldSatisfy` T.isInfixOf "--force\tUse generated content"+    (exclusiveExit, _, exclusiveErr) <-+      runProcessText binary ["update", "--run-all-commands", "--no-commands"] Nothing Nothing+    exclusiveExit `shouldSatisfy` (/= ExitSuccess)+    exclusiveErr `shouldSatisfy` T.isInfixOf "Invalid option"+    mapM_+      ( \shell -> do+          (shellExit, script, _) <- runProcessText binary ["completions", shell] Nothing Nothing+          shellExit `shouldBe` ExitSuccess+          script `shouldSatisfy` T.isInfixOf "bash-completion"+      )+      ["bash", "zsh", "fish"]++seihouBinary :: IO FilePath+seihouBinary = do+  testBinary <- getExecutablePath+  pure (takeDirectory (takeDirectory testBinary) </> "seihou" </> "seihou")++runSeihou :: FilePath -> UpdateFixture -> [String] -> IO (ExitCode, T.Text, T.Text)+runSeihou binary fixture args = do+  inherited <- getEnvironment+  let environment = ("XDG_CONFIG_HOME", fixture.xdgHome) : filter ((/= "XDG_CONFIG_HOME") . fst) inherited+  runProcessText binary args (Just fixture.projectRoot) (Just environment)++runProcessText binary args workingDirectory environment = do+  let command = (proc binary args) {cwd = workingDirectory, env = environment}+  (exitCode, stdoutText, stderrText) <- readCreateProcessWithExitCode command ""+  pure (exitCode, T.pack stdoutText, T.pack stderrText)++shouldReturnSatisfy :: IO T.Text -> (T.Text -> Bool) -> Expectation+shouldReturnSatisfy action predicate = action >>= (`shouldSatisfy` predicate)
+ test/Seihou/CLI/UpdateFixture.hs view
@@ -0,0 +1,135 @@+module Seihou.CLI.UpdateFixture+  ( minimalPlan,+    conflictPlan,+    unavailableConflictPlan,+    orphanPlan,+  )+where++import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Time (UTCTime, defaultTimeLocale, parseTimeOrError)+import Seihou.CLI.CommandExecution (CommandPlan (..), CommandPolicy (..))+import Seihou.CLI.Update+  ( InputChangeSummary (..),+    PromptPolicy (..),+    UpdatePlan (..),+    UpdateRequest (..),+    UpdateSelection (..),+  )+import Seihou.CLI.Update.Types (UpdateSnapshot (..))+import Seihou.Core.Types+  ( FileRecord (..),+    ModuleName (..),+    SHA256 (..),+    Strategy (..),+  )+import Seihou.Engine.Reconcile+  ( DesiredFile (..),+    FileReconciliation (..),+    ObservedFile (..),+    ReconciliationPlan (..),+    ReconciliationReason (..),+  )+import Seihou.Manifest.Types (emptyManifest)++fixedTime :: UTCTime+fixedTime =+  parseTimeOrError True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" "2026-07-19T12:00:00Z"++minimalPlan :: ReconciliationPlan -> UpdatePlan+minimalPlan reconciliation =+  UpdatePlan+    { applications = [],+      versionChanges = [],+      inputChanges = InputChangeSummary 0 0 0 0 [],+      migrations = [],+      reconciliation,+      commandPlan = CommandPlan [],+      candidateArtifacts = [],+      warnings = [],+      request =+        UpdateRequest+          { selection = AllRecordedApplications,+            varOverrides = [],+            reconfigure = False,+            promptPolicy = ForbidPrompts,+            commandPolicy = RunChangedCommands,+            dryRun = True+          },+      snapshot =+        UpdateSnapshot+          { sessionDirectory = "/tmp/session",+            projectRoot = "/tmp/project",+            manifestPath = "/tmp/project/.seihou/manifest.json",+            baselineDirectory = "/tmp/project/.seihou/baselines",+            installedDirectory = "/tmp/installed",+            originalManifest = emptyManifest fixedTime,+            candidateHashes = Map.empty,+            observedProjectHashes = Map.empty,+            transactionTargets = Set.empty+          },+      plannedApplications = []+    }++conflictPlan :: UpdatePlan+conflictPlan = minimalPlan (oneFile conflict)+  where+    conflict =+      FileConflict+        desired+        "title: user\nbody: old\n"+        "<<<<<<< current\ntitle: user\n||||||| baseline\ntitle: old\n=======\ntitle: generated\n>>>>>>> generated\n"+        OverlappingEdits+        (ObservedFile True (Just (SHA256 "current")))+        Nothing+        Nothing++unavailableConflictPlan :: UpdatePlan+unavailableConflictPlan = minimalPlan (oneFile conflict)+  where+    conflict =+      FileConflict+        desired+        "binary-current"+        "binary-current"+        (MergeDriverUnavailable "binary input")+        (ObservedFile True (Just (SHA256 "current")))+        Nothing+        Nothing++orphanPlan :: UpdatePlan+orphanPlan = minimalPlan (oneFile orphan)+  where+    orphan =+      FileOrphanEdited+        "README.md"+        FileRecord+          { hash = SHA256 "applied",+            moduleName = ModuleName "demo",+            strategy = Template,+            generatedAt = fixedTime,+            baseline = Nothing,+            applicationIds = Set.empty+          }+        "user edit"+        (ObservedFile True (Just (SHA256 "current")))+        Nothing++desired :: DesiredFile+desired =+  DesiredFile+    { path = "README.md",+      generatedContent = "title: generated\nbody: old\n",+      moduleName = ModuleName "demo",+      strategy = Template,+      applicationIds = Set.empty+    }++oneFile :: FileReconciliation -> ReconciliationPlan+oneFile reconciliation =+  ReconciliationPlan+    { applicationIds = Set.empty,+      files = Map.singleton "README.md" reconciliation,+      requiredDirectories = Set.empty+    }
+ test/Seihou/CLI/UpdateInteractionSpec.hs view
@@ -0,0 +1,67 @@+module Seihou.CLI.UpdateInteractionSpec (tests) where++import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Seihou.CLI.Update (UpdatePlan (..))+import Seihou.CLI.Update.Interaction+  ( InteractionError (..),+    InteractionMode (..),+    ResolutionDecision (..),+    applyResolutionDecisions,+    forceResolveUpdatePlan,+    resolveInteractively,+  )+import Seihou.CLI.UpdateFixture (conflictPlan, orphanPlan, unavailableConflictPlan)+import Seihou.Engine.Reconcile+  ( FileConflictChoice (..),+    FileReconciliation (..),+    OrphanChoice (..),+    ReconciliationPlan (..),+    ResolvedFileConflict (..),+    unresolvedPaths,+  )+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Seihou.CLI.Update.Interaction" spec++spec :: Spec+spec = do+  it "refuses unresolved plans in non-interactive mode" $ do+    result <- resolveInteractively NonInteractive conflictPlan+    result `shouldBe` Left (InteractionRequired (Set.singleton "README.md"))++  it "force accepts ordinary generated conflicts" $ do+    resolved <- expectResolved conflictPlan (forceResolveUpdatePlan conflictPlan)+    unresolvedPaths resolved.reconciliation `shouldBe` Set.empty+    case Map.lookup "README.md" resolved.reconciliation.files of+      Just (FileConflict _ _ _ _ _ _ (Just choice)) ->+        choice.choice `shouldBe` AcceptGenerated+      other -> expectationFailure ("expected resolved conflict, got " <> show other)++  it "force retains edited orphans as tracked state" $ do+    resolved <- expectResolved orphanPlan (forceResolveUpdatePlan orphanPlan)+    case Map.lookup "README.md" resolved.reconciliation.files of+      Just (FileOrphanEdited _ _ _ _ (Just choice)) ->+        choice `shouldBe` RetainTrackedOrphan+      other -> expectationFailure ("expected resolved orphan, got " <> show other)++  it "force leaves merge-driver failures unresolved" $ do+    resolved <- expectResolved unavailableConflictPlan (forceResolveUpdatePlan unavailableConflictPlan)+    unresolvedPaths resolved.reconciliation `shouldBe` Set.singleton "README.md"++  it "applies explicit keep-current and detach-orphan choices without writing" $ do+    kept <- expectResolved conflictPlan (applyResolutionDecisions [ResolveFile "README.md" KeepCurrent] conflictPlan)+    case Map.lookup "README.md" kept.reconciliation.files of+      Just (FileConflict _ _ _ _ _ _ (Just choice)) -> choice.choice `shouldBe` KeepCurrent+      other -> expectationFailure ("expected keep-current conflict resolution, got " <> show other)+    detached <- expectResolved orphanPlan (applyResolutionDecisions [ResolveOrphan "README.md" DetachAndKeepOrphan] orphanPlan)+    case Map.lookup "README.md" detached.reconciliation.files of+      Just (FileOrphanEdited _ _ _ _ (Just choice)) -> choice `shouldBe` DetachAndKeepOrphan+      other -> expectationFailure ("expected detached orphan resolution, got " <> show other)++expectResolved fallback result = case result of+  Left err -> expectationFailure (show err) >> pure fallback+  Right resolved -> pure resolved
+ test/Seihou/CLI/UpdateRenderSpec.hs view
@@ -0,0 +1,38 @@+module Seihou.CLI.UpdateRenderSpec (tests) where++import Data.List (isInfixOf)+import Data.Text qualified as T+import Seihou.CLI.Update (UpdateError (..))+import Seihou.CLI.Update.Render+  ( encodeUpdateOutput,+    errorOutput,+    planOutput,+    renderUpdateHuman,+  )+import Seihou.CLI.UpdateFixture (conflictPlan)+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Seihou.CLI.Update.Render" spec++spec :: Spec+spec = do+  it "groups a human plan and names unresolved paths" $ do+    let rendered = renderUpdateHuman False (planOutput conflictPlan)+    rendered `shouldSatisfy` T.isInfixOf "Files:"+    rendered `shouldSatisfy` T.isInfixOf "Conflict:    README.md"+    rendered `shouldSatisfy` T.isInfixOf "Commands:"++  it "emits one versioned JSON plan document" $ do+    let rendered = show (encodeUpdateOutput (planOutput conflictPlan))+    rendered `shouldSatisfy` isInfixOf "\\\"schemaVersion\\\":1"+    rendered `shouldSatisfy` isInfixOf "\\\"outcome\\\":\\\"plan\\\""+    rendered `shouldSatisfy` isInfixOf "\\\"classification\\\":\\\"conflict\\\""++  it "uses a stable machine error code" $ do+    let rendered = show (encodeUpdateOutput (errorOutput (UpdateManifestMissing ".seihou/manifest.json")))+    rendered `shouldSatisfy` isInfixOf "manifest_missing"+    renderUpdateHuman False (errorOutput (UpdateManifestMissing "manifest"))+      `shouldSatisfy` T.isInfixOf "Update failed [manifest_missing]"
+ test/Seihou/CLI/UpdateSpec.hs view
@@ -0,0 +1,738 @@+module Seihou.CLI.UpdateSpec+  ( tests,+    UpdateFixture (..),+    prepareUpdateFixture,+  )+where++import Control.Exception (bracket)+import Data.ByteString.Lazy qualified as LBS+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe, listToMaybe)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Data.Time (UTCTime (..), fromGregorian)+import Seihou.CLI.CommandExecution (CommandPolicy (..))+import Seihou.CLI.Update (PromptPolicy (..), UpdateRequest (..), UpdateSelection (..), applyProjectUpdate, isUpdateNoOp, withProjectUpdate)+import Seihou.CLI.Update.Migrations (StagedMigrations (..), planAndStageMigrations)+import Seihou.CLI.Update.Selection+import Seihou.CLI.Update.Source+import Seihou.CLI.Update.Types+import Seihou.Composition.Instance (ModuleInstance (..))+import Seihou.Core.Application (mkApplicationId)+import Seihou.Core.CommandFingerprint (fingerprintCommand)+import Seihou.Core.Migration (Migration (..), MigrationOp (..))+import Seihou.Core.Types+import Seihou.Manifest.Hash (hashContent)+import Seihou.Manifest.Types (emptyManifest, manifestFromJSON, manifestToJSON)+import System.Directory (createDirectoryIfMissing, doesFileExist, withCurrentDirectory)+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Process (callProcess)+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Seihou.CLI.Update" spec++spec :: Spec+spec = do+  describe "application selection" $ do+    it "selects every application containing a requested bare module" $ do+      let first = application (AppliedModuleTarget "one") [instanceState "shared" "/one/shared"]+          second = application (AppliedRecipeTarget "stack") [instanceState "shared" "/two/shared"]+          manifest :: Manifest+          manifest = manifestForApplications [first, second] Map.empty+      selectApplications (NamedUpdateTargets ["shared"]) manifest+        `shouldBe` Right (RecordedSelection [first, second])++    it "keeps manifest order for all applications and deduplicates repeated targets" $ do+      let first = application (AppliedModuleTarget "one") [instanceState "one" "/one"]+          second = application (AppliedModuleTarget "two") [instanceState "two" "/two"]+          manifest :: Manifest+          manifest = manifestForApplications [first, second] Map.empty+      selectApplications AllRecordedApplications manifest+        `shouldBe` Right (RecordedSelection [first, second])+      selectApplications (NamedUpdateTargets ["two", "two", "one"]) manifest+        `shouldBe` Right (RecordedSelection [first, second])++    it "rejects a partial selection that shares an owned path" $ do+      let first = application (AppliedModuleTarget "one") [instanceState "one" "/one"]+          second = application (AppliedModuleTarget "two") [instanceState "two" "/two"]+          owners = Set.fromList [first.applicationId, second.applicationId]+          record = FileRecord (hashContent "old") "one" Template testTime Nothing owners+          manifest :: Manifest+          manifest = manifestForApplications [first, second] (Map.singleton "shared.txt" record)+      selectApplications (NamedUpdateTargets ["one"]) manifest+        `shouldBe` Left (SharedPathRequiresApplications "shared.txt" (Set.singleton first.applicationId) (Set.singleton second.applicationId))++    it "requires one explicit target to seed a legacy manifest" $ do+      selectApplications AllRecordedApplications (emptyManifest testTime) `shouldBe` Left NoRecordedApplications+      selectApplications (NamedUpdateTargets ["one", "two"]) (emptyManifest testTime)+        `shouldBe` Left LegacyUpdateRequiresOneTarget++  describe "candidate source staging" $ do+    it "keeps local artifacts as an explicit candidate-first fallback" $+      withSystemTempDirectory "seihou-update-source" $ \root -> do+        let moduleDirectory = root </> "current" </> "demo"+            sessionDirectory = root </> "session"+            applied = application (AppliedModuleTarget "demo") [instanceState "demo" moduleDirectory]+        createDirectoryIfMissing True moduleDirectory+        TIO.writeFile (moduleDirectory </> "module.dhall") (moduleDhall "demo" "1.0.0")+        result <- stageCandidateSources sessionDirectory [applied {targetSource = moduleDirectory}]+        case result of+          Left err -> expectationFailure (show err)+          Right (catalog, warnings) -> do+            warnings `shouldContain` [LocalArtifactHasNoRemote "demo"]+            let candidate = catalog.artifacts Map.! (CandidateModule, "demo")+            candidate.originalDirectory `shouldBe` moduleDirectory+            candidate.sourceUrl `shouldBe` Nothing+            doesFileExist (catalog.searchRoot </> "demo" </> "module.dhall") `shouldReturn` True++    it "clones one registry origin once for a recipe and all of its modules" $+      withSystemTempDirectory "seihou-update-registry-source" $ \root -> do+        let remote = root </> "remote"+            installed = root </> "installed"+            moduleOne = installed </> "one"+            moduleTwo = installed </> "two"+            recipeDirectory = installed </> "stack"+            sessionDirectory = root </> "session"+            sourceUrl = T.pack remote+            applied =+              (application (AppliedRecipeTarget "stack") [instanceState "one" moduleOne, instanceState "two" moduleTwo])+                { targetSource = recipeDirectory,+                  additionalModules = []+                }+        createDirectoryIfMissing True (remote </> "modules" </> "one")+        createDirectoryIfMissing True (remote </> "modules" </> "two")+        createDirectoryIfMissing True (remote </> "recipes" </> "stack")+        TIO.writeFile (remote </> "modules" </> "one" </> "module.dhall") (moduleDhall "one" "2.0.0")+        TIO.writeFile (remote </> "modules" </> "two" </> "module.dhall") (moduleDhall "two" "2.0.0")+        TIO.writeFile (remote </> "recipes" </> "stack" </> "recipe.dhall") (recipeDhall "stack" "2.0.0" ["one", "two"])+        TIO.writeFile (remote </> "seihou-registry.dhall") registryDhall+        callProcess "git" ["-C", remote, "init", "-q"]+        callProcess "git" ["-C", remote, "add", "."]+        callProcess "git" ["-C", remote, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "commit", "-qm", "registry"]+        mapM_ (writeOrigin sourceUrl) [moduleOne, moduleTwo, recipeDirectory]+        result <- stageCandidateSources sessionDirectory [applied]+        case result of+          Left err -> expectationFailure (show err)+          Right (catalog, _) -> do+            Map.size catalog.clonedOrigins `shouldBe` 1+            Map.keysSet catalog.artifacts+              `shouldBe` Set.fromList [(CandidateModule, "one"), (CandidateModule, "two"), (CandidateRecipe, "stack")]++    it "returns a structured clone error before touching the project" $+      withSystemTempDirectory "seihou-update-clone-error" $ \root -> do+        let moduleDirectory = root </> "installed" </> "demo"+            missingRemote = T.pack (root </> "missing-remote")+            applied = (application (AppliedModuleTarget "demo") [instanceState "demo" moduleDirectory]) {targetSource = moduleDirectory}+        writeOrigin missingRemote moduleDirectory+        result <- stageCandidateSources (root </> "session") [applied]+        result `shouldSatisfy` \case+          Left (CandidateCloneFailed url message) -> url == missingRemote && "git clone failed" `T.isInfixOf` message+          _ -> False++  describe "staged update service" $ do+    it "reuses accepted inputs, keeps dry-run read-only, and publishes one coherent update" $+      withSystemTempDirectory "seihou-update-e2e" $ \root -> do+        fixture <- prepareUpdateFixture root+        withSavedEnv "XDG_CONFIG_HOME" (Just fixture.xdgHome) $+          withCurrentDirectory fixture.projectRoot $ do+            beforeManifest <- LBS.readFile fixture.manifestPath+            beforeProject <- TIO.readFile fixture.projectFile+            beforeInstalled <- TIO.readFile (fixture.installedModule </> "module.dhall")+            let dryRequest = updateRequest True+            dryResult <- withProjectUpdate dryRequest $ \case+              Left err -> pure (Left err)+              Right plan -> applyProjectUpdate plan+            case dryResult of+              Left err -> expectationFailure (show err)+              Right _ -> pure ()+            LBS.readFile fixture.manifestPath `shouldReturn` beforeManifest+            TIO.readFile fixture.projectFile `shouldReturn` beforeProject+            TIO.readFile (fixture.installedModule </> "module.dhall") `shouldReturn` beforeInstalled++            applied <- withProjectUpdate (updateRequest False) $ \case+              Left err -> pure (Left err)+              Right plan -> applyProjectUpdate plan+            case applied of+              Left err -> expectationFailure (show err)+              Right result -> do+                result.versions `shouldSatisfy` any (\change -> change.name == "demo" && change.fromVersion == Just "1.0.0" && change.toVersion == Just "2.0.0")+                result.updatedApplications `shouldBe` [fixture.applicationId]+            TIO.readFile fixture.projectFile `shouldReturn` "hello accepted\nkeep\nv2\n"+            installedBytes <- TIO.readFile (fixture.installedModule </> "module.dhall")+            installedBytes `shouldSatisfy` T.isInfixOf "Some \"2.0.0\""+            decoded <- manifestFromJSON <$> LBS.readFile fixture.manifestPath+            case decoded of+              Left err -> expectationFailure err+              Right manifest -> case manifest.applications of+                updated : _ -> case updated.instances of+                  instanceState : _ -> do+                    instanceState.resolvedVars `shouldBe` Map.singleton "project.name" "accepted"+                    instanceState.moduleVersion `shouldBe` Just "2.0.0"+                  [] -> expectationFailure "updated application has no instances"+                [] -> expectationFailure "updated manifest has no applications"++            afterFirstApply <- LBS.readFile fixture.manifestPath+            noOp <- withProjectUpdate (updateRequest False) $ \case+              Left err -> pure (Left err)+              Right plan -> applyProjectUpdate plan+            case noOp of+              Left err -> expectationFailure (show err)+              Right result -> result.updatedApplications `shouldBe` []+            LBS.readFile fixture.manifestPath `shouldReturn` afterFirstApply++    it "rejects a plan when its manifest snapshot changes" $+      withSystemTempDirectory "seihou-update-stale" $ \root -> do+        fixture <- prepareUpdateFixture root+        withSavedEnv "XDG_CONFIG_HOME" (Just fixture.xdgHome) $+          withCurrentDirectory fixture.projectRoot $ do+            result <- withProjectUpdate (updateRequest False) $ \case+              Left err -> pure (Left err)+              Right plan -> do+                TIO.appendFile fixture.manifestPath "\n"+                applyProjectUpdate plan+            result `shouldSatisfy` \case+              Left (UpdatePlanStale paths) -> Set.member (".seihou" </> "manifest.json") paths+              _ -> False++    it "plans changed content at the same declared version with an explicit warning" $+      withSystemTempDirectory "seihou-update-same-version" $ \root -> do+        fixture <- prepareUpdateFixture root+        let modulePath = fixture.remote </> "module.dhall"+        body <- TIO.readFile modulePath+        TIO.writeFile modulePath (T.replace "Some \"2.0.0\"" "Some \"1.0.0\"" body)+        callProcess "git" ["-C", fixture.remote, "add", "module.dhall"]+        callProcess "git" ["-C", fixture.remote, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "commit", "-qm", "same-version content change"]+        withSavedEnv "XDG_CONFIG_HOME" (Just fixture.xdgHome) $+          withCurrentDirectory fixture.projectRoot $ do+            result <- withProjectUpdate (updateRequest True) pure+            case result of+              Left err -> expectationFailure (show err)+              Right plan -> do+                isUpdateNoOp plan `shouldBe` False+                plan.versionChanges `shouldSatisfy` any (.sameVersionContentChanged)+                plan.warnings `shouldContain` [SameVersionContentChanged "demo"]++    it "re-expands a candidate recipe and removes dependencies dropped by it" $+      withSystemTempDirectory "seihou-update-recipe" $ \root -> do+        fixture <- prepareRecipeUpdateFixture root+        withSavedEnv "XDG_CONFIG_HOME" (Just fixture.recipeXdgHome) $+          withCurrentDirectory fixture.recipeProjectRoot $ do+            result <- withProjectUpdate (updateRequest False) $ \case+              Left err -> pure (Left err)+              Right plan -> applyProjectUpdate plan+            case result of+              Left err -> expectationFailure (show err)+              Right updateResult -> updateResult.updatedApplications `shouldBe` [fixture.recipeApplicationId]+            decoded <- manifestFromJSON <$> LBS.readFile fixture.recipeManifestPath+            case decoded of+              Left err -> expectationFailure err+              Right manifest -> case manifest.applications of+                [updated] -> do+                  updated.applicationId `shouldBe` fixture.recipeApplicationId+                  updated.targetVersion `shouldBe` Just "2.0.0"+                  updated.additionalModules `shouldBe` []+                  Set.fromList (map (.name) updated.instances) `shouldBe` Set.fromList ["one", "new"]+                  Set.fromList (map (.name) manifest.modules) `shouldBe` Set.fromList ["one", "new"]+                other -> expectationFailure ("expected one updated recipe application, got " <> show other)+            doesFileExist (fixture.recipeXdgHome </> "seihou" </> "installed" </> "new" </> "module.dhall") `shouldReturn` True++    it "refuses an unresolved three-way conflict without mutating durable state" $+      withSystemTempDirectory "seihou-update-conflict" $ \root -> do+        fixture <- prepareUpdateFixture root+        TIO.writeFile (fixture.remote </> "files" </> "README.tmpl") "candidate {{project.name}}\nv2\n"+        callProcess "git" ["-C", fixture.remote, "add", "files/README.tmpl"]+        callProcess "git" ["-C", fixture.remote, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "commit", "-qm", "conflicting template"]+        withSavedEnv "XDG_CONFIG_HOME" (Just fixture.xdgHome) $+          withCurrentDirectory fixture.projectRoot $ do+            TIO.writeFile fixture.projectFile "user accepted\nv1\n"+            beforeManifest <- LBS.readFile fixture.manifestPath+            beforeInstalled <- LBS.readFile (fixture.installedModule </> "module.dhall")+            result <- withProjectUpdate (updateRequest False) $ \case+              Left err -> pure (Left err)+              Right plan -> applyProjectUpdate plan+            result `shouldSatisfy` \case+              Left (UpdateHasUnresolvedPaths paths) -> Set.member "README.md" paths+              _ -> False+            LBS.readFile fixture.manifestPath `shouldReturn` beforeManifest+            TIO.readFile fixture.projectFile `shouldReturn` "user accepted\nv1\n"+            LBS.readFile (fixture.installedModule </> "module.dhall") `shouldReturn` beforeInstalled++    it "seeds one explicit legacy target and records it only after success" $+      withSystemTempDirectory "seihou-update-legacy" $ \root -> do+        fixture <- prepareUpdateFixture root+        decoded <- manifestFromJSON <$> LBS.readFile fixture.manifestPath+        legacy <- case decoded of+          Left err -> expectationFailure err >> pure (emptyManifest testTime)+          Right manifest -> pure (withoutApplications manifest)+        LBS.writeFile fixture.manifestPath (manifestToJSON legacy)+        let request = (updateRequest False) {selection = NamedUpdateTargets ["demo"]}+        withSavedEnv "XDG_CONFIG_HOME" (Just fixture.xdgHome) $+          withCurrentDirectory fixture.projectRoot $ do+            result <- withProjectUpdate request $ \case+              Left err -> pure (Left err)+              Right plan -> applyProjectUpdate plan+            case result of+              Left err -> expectationFailure (show err)+              Right updateResult -> updateResult.updatedApplications `shouldBe` [fixture.applicationId]+            updated <- manifestFromJSON <$> LBS.readFile fixture.manifestPath+            case updated of+              Left err -> expectationFailure err+              Right manifest -> case manifest.applications of+                [applicationState] -> case applicationState.instances of+                  [moduleState] -> moduleState.resolvedVars `shouldBe` Map.singleton "project.name" "accepted"+                  other -> expectationFailure ("expected one legacy module instance, got " <> show other)+                other -> expectationFailure ("expected one seeded application, got " <> show other)++    it "rolls managed project and cache state back when a candidate command fails" $+      withSystemTempDirectory "seihou-update-command-failure" $ \root -> do+        fixture <- prepareUpdateFixture root+        let modulePath = fixture.remote </> "module.dhall"+        body <- TIO.readFile modulePath+        TIO.writeFile+          modulePath+          ( T.replace+              ", commands = [{ run = \"printf should-not-run >> command.log\", workDir = None Text, when = None Text }]"+              ", commands = [{ run = \"exit 7\", workDir = None Text, when = None Text }]"+              body+          )+        callProcess "git" ["-C", fixture.remote, "add", "module.dhall"]+        callProcess "git" ["-C", fixture.remote, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "commit", "-qm", "failing command"]+        withSavedEnv "XDG_CONFIG_HOME" (Just fixture.xdgHome) $+          withCurrentDirectory fixture.projectRoot $ do+            beforeManifest <- LBS.readFile fixture.manifestPath+            beforeProject <- TIO.readFile fixture.projectFile+            beforeInstalled <- LBS.readFile (fixture.installedModule </> "module.dhall")+            result <- withProjectUpdate (updateRequest False) $ \case+              Left err -> pure (Left err)+              Right plan -> applyProjectUpdate plan+            result `shouldSatisfy` \case+              Left UpdateCommandFailed {} -> True+              _ -> False+            LBS.readFile fixture.manifestPath `shouldReturn` beforeManifest+            TIO.readFile fixture.projectFile `shouldReturn` beforeProject+            LBS.readFile (fixture.installedModule </> "module.dhall") `shouldReturn` beforeInstalled++    it "rolls managed state back when installed-cache publication fails" $+      withSystemTempDirectory "seihou-update-cache-failure" $ \root -> do+        fixture <- prepareUpdateFixture root+        withSavedEnv "XDG_CONFIG_HOME" (Just fixture.xdgHome) $+          withCurrentDirectory fixture.projectRoot $ do+            beforeManifest <- LBS.readFile fixture.manifestPath+            beforeProject <- TIO.readFile fixture.projectFile+            beforeInstalled <- LBS.readFile (fixture.installedModule </> "module.dhall")+            result <- withProjectUpdate (updateRequest False) $ \case+              Left err -> pure (Left err)+              Right plan -> applyProjectUpdate (breakCandidatePublication plan)+            result `shouldSatisfy` \case+              Left UpdateCachePublicationFailed {} -> True+              _ -> False+            LBS.readFile fixture.manifestPath `shouldReturn` beforeManifest+            TIO.readFile fixture.projectFile `shouldReturn` beforeProject+            LBS.readFile (fixture.installedModule </> "module.dhall") `shouldReturn` beforeInstalled++  describe "migration staging" $ do+    it "preserves parameterized instances while planning their shared transition once" $+      withSystemTempDirectory "seihou-update-shared-migration" $ \projectRoot -> do+        let parentOne = ParentVars (Map.singleton "tenant" "one")+            parentTwo = ParentVars (Map.singleton "tenant" "two")+            instanceOne = ModuleInstance "shared" parentOne+            instanceTwo = ModuleInstance "shared" parentTwo+            stateFor parent = AppliedInstanceState "shared" parent "/installed/shared" (Just "1.0.0") Map.empty+            previous = application (AppliedModuleTarget "shared") [stateFor parentOne, stateFor parentTwo]+            candidate =+              Module+                { name = "shared",+                  version = Just "2.0.0",+                  description = Nothing,+                  vars = [],+                  exports = [],+                  prompts = [],+                  steps = [],+                  commands = [],+                  dependencies = [],+                  removal = Nothing,+                  migrations = [Migration "1.0.0" "2.0.0" [RunCommand "true" Nothing]]+                }+            appliedModules =+              [ AppliedModule "shared" parentOne "/installed/shared" (Just "1.0.0") testTime Nothing,+                AppliedModule "shared" parentTwo "/installed/shared" (Just "1.0.0") testTime Nothing+              ]+            base = emptyManifest testTime+            manifest =+              Manifest+                { version = base.version,+                  genAt = base.genAt,+                  modules = appliedModules,+                  vars = Map.empty,+                  files = Map.empty,+                  applications = [previous],+                  recipe = Nothing,+                  blueprint = Nothing,+                  blueprintMigrations = []+                }+            catalog = CandidateCatalog (projectRoot </> "search") Map.empty Map.empty+            candidates = [(instanceOne, candidate, "/candidate/shared"), (instanceTwo, candidate, "/candidate/shared")]+        staged <- planAndStageMigrations projectRoot manifest catalog [(Just previous, candidates)]+        case staged of+          Left err -> expectationFailure (show err)+          Right migrationStage -> do+            length migrationStage.plans `shouldBe` 1+            migrationStage.plans `shouldSatisfy` all (.containsCommands)+            migrationStage.warnings `shouldBe` [MigrationCommandNotSimulated "shared" "true"]+            map (.moduleVersion) migrationStage.manifest.modules `shouldBe` [Just "2.0.0", Just "2.0.0"]++data UpdateFixture = UpdateFixture+  { projectRoot :: FilePath,+    projectFile :: FilePath,+    manifestPath :: FilePath,+    xdgHome :: FilePath,+    installedModule :: FilePath,+    remote :: FilePath,+    applicationId :: ApplicationId+  }++data RecipeUpdateFixture = RecipeUpdateFixture+  { recipeProjectRoot :: FilePath,+    recipeManifestPath :: FilePath,+    recipeXdgHome :: FilePath,+    recipeApplicationId :: ApplicationId+  }++prepareUpdateFixture :: FilePath -> IO UpdateFixture+prepareUpdateFixture root = do+  let projectRoot = root </> "project"+      manifestPath = projectRoot </> ".seihou" </> "manifest.json"+      projectFile = projectRoot </> "README.md"+      remote = root </> "remote"+      xdgHome = root </> "xdg"+      installedModule = xdgHome </> "seihou" </> "installed" </> "demo"+      unchangedCommand = "printf should-not-run >> command.log"+      commandOperation = RunCommandOp unchangedCommand Nothing "demo" 0+      commandFingerprint = fromMaybe (error "test fixture command fingerprint") (fingerprintCommand commandOperation)+      commandReceipt = CommandReceipt commandFingerprint "demo" unchangedCommand Nothing testTime+      baselineContent = "hello accepted\nkeep\nv1\n"+      baselineRef = BaselineRef (hashContent baselineContent)+      target = AppliedModuleTarget "demo"+      applicationId = mkApplicationId target []+      app =+        (application target [instanceState "demo" installedModule])+          { applicationId,+            targetSource = installedModule,+            targetVersion = Just "1.0.0",+            commandReceipts = Map.singleton commandFingerprint commandReceipt,+            instances =+              [ (instanceState "demo" installedModule)+                  { resolvedVars = Map.singleton "project.name" "accepted"+                  }+              ]+          }+      appliedModule = AppliedModule "demo" emptyParentVars installedModule (Just "1.0.0") testTime Nothing+      fileRecord =+        FileRecord+          (hashContent baselineContent)+          "demo"+          Template+          testTime+          (Just baselineRef)+          (Set.singleton applicationId)+      manifest =+        (emptyManifest testTime)+          { modules = [appliedModule],+            vars = Map.singleton "project.name" "accepted",+            files = Map.singleton "README.md" fileRecord,+            applications = [app]+          }+  createDirectoryIfMissing True (installedModule </> "files")+  TIO.writeFile (installedModule </> "module.dhall") (moduleDhallWithTemplate "demo" "1.0.0" "old-default")+  TIO.writeFile (installedModule </> "files" </> "README.tmpl") "hello {{project.name}}\nkeep\nv1\n"+  createDirectoryIfMissing True (remote </> "files")+  TIO.writeFile (remote </> "module.dhall") (moduleDhallWithTemplate "demo" "2.0.0" "new-default")+  TIO.writeFile (remote </> "files" </> "README.tmpl") "hello {{project.name}}\nkeep\nv2\n"+  callProcess "git" ["-C", remote, "init", "-q"]+  callProcess "git" ["-C", remote, "add", "."]+  callProcess "git" ["-C", remote, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "commit", "-qm", "v2"]+  TIO.writeFile+    (installedModule </> ".seihou-origin.json")+    ("{\"sourceUrl\":\"" <> T.pack remote <> "\",\"version\":\"1.0.0\"}")+  createDirectoryIfMissing True (projectRoot </> ".seihou" </> "baselines")+  TIO.writeFile projectFile baselineContent+  TIO.writeFile+    (projectRoot </> ".seihou" </> "baselines" </> T.unpack baselineRef.unBaselineRef.unSHA256)+    baselineContent+  LBS.writeFile manifestPath (manifestToJSON manifest)+  pure UpdateFixture {projectRoot, projectFile, manifestPath, xdgHome, installedModule, remote, applicationId}++prepareRecipeUpdateFixture :: FilePath -> IO RecipeUpdateFixture+prepareRecipeUpdateFixture root = do+  let projectRoot = root </> "project"+      manifestPath = projectRoot </> ".seihou" </> "manifest.json"+      remote = root </> "remote"+      xdgHome = root </> "xdg"+      installedRoot = xdgHome </> "seihou" </> "installed"+      installedOne = installedRoot </> "one"+      installedOld = installedRoot </> "old"+      installedRecipe = installedRoot </> "stack"+      target = AppliedRecipeTarget "stack"+      applicationId = mkApplicationId target []+      app =+        AppliedComposition+          { applicationId,+            target,+            targetSource = installedRecipe,+            targetVersion = Just "1.0.0",+            additionalModules = [],+            namespace = Just "one",+            context = Nothing,+            instances = [instanceState "old" installedOld, instanceState "one" installedOne],+            commandReceipts = Map.empty,+            appliedAt = testTime+          }+      base = emptyManifest testTime+      manifest =+        Manifest+          { version = base.version,+            genAt = base.genAt,+            modules =+              [ AppliedModule "old" emptyParentVars installedOld (Just "1.0.0") testTime Nothing,+                AppliedModule "one" emptyParentVars installedOne (Just "1.0.0") testTime Nothing+              ],+            vars = Map.empty,+            files = Map.empty,+            applications = [app],+            recipe = Just (AppliedRecipe "stack" (Just "1.0.0") testTime),+            blueprint = Nothing,+            blueprintMigrations = []+          }+      sourceUrl = T.pack remote+  createDirectoryIfMissing True installedOne+  createDirectoryIfMissing True installedOld+  createDirectoryIfMissing True installedRecipe+  TIO.writeFile (installedOne </> "module.dhall") (moduleDhall "one" "1.0.0")+  TIO.writeFile (installedOld </> "module.dhall") (moduleDhall "old" "1.0.0")+  TIO.writeFile (installedRecipe </> "recipe.dhall") (recipeDhall "stack" "1.0.0" ["one", "old"])+  mapM_ (writeOrigin sourceUrl) [installedOne, installedOld, installedRecipe]+  createDirectoryIfMissing True (remote </> "modules" </> "one")+  createDirectoryIfMissing True (remote </> "modules" </> "old")+  createDirectoryIfMissing True (remote </> "modules" </> "new")+  createDirectoryIfMissing True (remote </> "recipes" </> "stack")+  TIO.writeFile (remote </> "modules" </> "one" </> "module.dhall") (moduleDhall "one" "2.0.0")+  TIO.writeFile (remote </> "modules" </> "old" </> "module.dhall") (moduleDhall "old" "1.0.0")+  TIO.writeFile (remote </> "modules" </> "new" </> "module.dhall") (moduleDhall "new" "1.0.0")+  TIO.writeFile (remote </> "recipes" </> "stack" </> "recipe.dhall") (recipeDhall "stack" "2.0.0" ["one", "new"])+  TIO.writeFile (remote </> "seihou-registry.dhall") recipeUpdateRegistryDhall+  callProcess "git" ["-C", remote, "init", "-q"]+  callProcess "git" ["-C", remote, "add", "."]+  callProcess "git" ["-C", remote, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "commit", "-qm", "recipe v2"]+  createDirectoryIfMissing True (projectRoot </> ".seihou")+  LBS.writeFile manifestPath (manifestToJSON manifest)+  pure+    RecipeUpdateFixture+      { recipeProjectRoot = projectRoot,+        recipeManifestPath = manifestPath,+        recipeXdgHome = xdgHome,+        recipeApplicationId = applicationId+      }++updateRequest :: Bool -> UpdateRequest+updateRequest dryRun =+  UpdateRequest+    { selection = AllRecordedApplications,+      varOverrides = [],+      reconfigure = False,+      promptPolicy = ForbidPrompts,+      commandPolicy = RunChangedCommands,+      dryRun+    }++moduleDhallWithTemplate :: Text -> Text -> Text -> Text+moduleDhallWithTemplate name version defaultValue =+  T.unlines+    [ "{ name = \"" <> name <> "\"",+      ", version = Some \"" <> version <> "\"",+      ", description = None Text",+      ", vars = [{ name = \"project.name\", type = \"text\", default = Some \"" <> defaultValue <> "\", description = None Text, required = False, validation = None Text }]",+      ", exports = [] : List { var : Text, alias : Optional Text }",+      ", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",+      ", steps = [{ strategy = \"template\", src = \"README.tmpl\", dest = \"README.md\", when = None Text, patch = None Text }]",+      ", commands = [{ run = \"printf should-not-run >> command.log\", workDir = None Text, when = None Text }]",+      ", dependencies = [] : List Text",+      ", removal = None { steps : List { action : Text, dest : Text, src : Optional Text }, commands : List { run : Text, workDir : Optional Text, when : Optional Text } }",+      "}"+    ]++withSavedEnv :: String -> Maybe String -> IO a -> IO a+withSavedEnv key value action =+  bracket+    (lookupEnv key <* setValue value)+    setValue+    (const action)+  where+    setValue (Just current) = setEnv key current+    setValue Nothing = unsetEnv key++application :: AppliedTarget -> [AppliedInstanceState] -> AppliedComposition+application target instances =+  AppliedComposition+    { applicationId = mkApplicationId target [],+      target,+      targetSource = maybe "" (.source) (listToMaybe instances),+      targetVersion = Just "1.0.0",+      additionalModules = [],+      namespace = Nothing,+      context = Nothing,+      instances,+      commandReceipts = Map.empty,+      appliedAt = testTime+    }++instanceState :: ModuleName -> FilePath -> AppliedInstanceState+instanceState name source =+  AppliedInstanceState+    { name,+      parentVars = emptyParentVars,+      source,+      moduleVersion = Just "1.0.0",+      resolvedVars = Map.empty+    }++moduleDhall :: Text -> Text -> Text+moduleDhall name version =+  T.unlines+    [ "{ name = \"" <> name <> "\"",+      ", version = Some \"" <> version <> "\"",+      ", description = None Text",+      ", vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }",+      ", exports = [] : List { var : Text, alias : Optional Text }",+      ", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",+      ", steps = [] : List { strategy : Text, src : Text, dest : Text, when : Optional Text, patch : Optional Text }",+      ", commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }",+      ", dependencies = [] : List Text",+      ", removal = None { steps : List { action : Text, dest : Text, src : Optional Text }, commands : List { run : Text, workDir : Optional Text, when : Optional Text } }",+      "}"+    ]++recipeDhall :: Text -> Text -> [Text] -> Text+recipeDhall name version modules =+  T.unlines+    [ "{ name = \"" <> name <> "\"",+      ", version = Some \"" <> version <> "\"",+      ", description = None Text",+      ", modules = ["+        <> T.intercalate+          ", "+          [ "{ module = \"" <> moduleName <> "\", vars = [] : List { name : Text, value : Text } }"+          | moduleName <- modules+          ]+        <> "]",+      ", vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }",+      ", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",+      "}"+    ]++registryDhall :: Text+registryDhall =+  T.unlines+    [ "{ repoName = \"update-test\"",+      ", repoDescription = None Text",+      ", modules =",+      "  [ { name = \"one\", version = Some \"2.0.0\", path = \"modules/one\", description = None Text, tags = [] : List Text }",+      "  , { name = \"two\", version = Some \"2.0.0\", path = \"modules/two\", description = None Text, tags = [] : List Text }",+      "  ]",+      ", recipes = [{ name = \"stack\", version = Some \"2.0.0\", path = \"recipes/stack\", description = None Text, tags = [] : List Text }]",+      ", blueprints = [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }",+      ", prompts = [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }",+      "}"+    ]++recipeUpdateRegistryDhall :: Text+recipeUpdateRegistryDhall =+  T.unlines+    [ "{ repoName = \"recipe-update-test\"",+      ", repoDescription = None Text",+      ", modules =",+      "  [ { name = \"one\", version = Some \"2.0.0\", path = \"modules/one\", description = None Text, tags = [] : List Text }",+      "  , { name = \"old\", version = Some \"1.0.0\", path = \"modules/old\", description = None Text, tags = [] : List Text }",+      "  , { name = \"new\", version = Some \"1.0.0\", path = \"modules/new\", description = None Text, tags = [] : List Text }",+      "  ]",+      ", recipes = [{ name = \"stack\", version = Some \"2.0.0\", path = \"recipes/stack\", description = None Text, tags = [] : List Text }]",+      ", blueprints = [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }",+      ", prompts = [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }",+      "}"+    ]++writeOrigin :: Text -> FilePath -> IO ()+writeOrigin sourceUrl directory = do+  createDirectoryIfMissing True directory+  TIO.writeFile (directory </> ".seihou-origin.json") ("{\"sourceUrl\":\"" <> sourceUrl <> "\"}")++manifestForApplications :: [AppliedComposition] -> Map.Map FilePath FileRecord -> Manifest+manifestForApplications applicationRecords fileRecords =+  let base = emptyManifest testTime+   in Manifest+        { version = base.version,+          genAt = base.genAt,+          modules = base.modules,+          vars = base.vars,+          files = fileRecords,+          applications = applicationRecords,+          recipe = base.recipe,+          blueprint = base.blueprint,+          blueprintMigrations = base.blueprintMigrations+        }++breakCandidatePublication :: UpdatePlan -> UpdatePlan+breakCandidatePublication plan =+  UpdatePlan+    { applications = plan.applications,+      versionChanges = plan.versionChanges,+      inputChanges = plan.inputChanges,+      migrations = plan.migrations,+      reconciliation = plan.reconciliation,+      commandPlan = plan.commandPlan,+      candidateArtifacts = map breakArtifact plan.candidateArtifacts,+      warnings = plan.warnings,+      request = plan.request,+      snapshot = plan.snapshot,+      plannedApplications = plan.plannedApplications+    }+  where+    breakArtifact artifact =+      CandidateArtifact+        { kind = artifact.kind,+          name = artifact.name,+          version = artifact.version,+          originalDirectory = plan.snapshot.sessionDirectory </> "missing-publication-source",+          sourceDirectory = artifact.sourceDirectory,+          sourceUrl = artifact.sourceUrl,+          repoName = artifact.repoName,+          tags = artifact.tags,+          sourceRevision = artifact.sourceRevision,+          contentHash = artifact.contentHash,+          moduleDefinition = artifact.moduleDefinition,+          recipeDefinition = artifact.recipeDefinition+        }++withoutApplications :: Manifest -> Manifest+withoutApplications manifest =+  Manifest+    { version = manifest.version,+      genAt = manifest.genAt,+      modules = manifest.modules,+      vars = manifest.vars,+      files = manifest.files,+      applications = [],+      recipe = manifest.recipe,+      blueprint = manifest.blueprint,+      blueprintMigrations = manifest.blueprintMigrations+    }++testTime :: UTCTime+testTime = UTCTime (fromGregorian 2026 7 19) 0