packages feed

okf-cli 0.6.0.1 → 0.7.0.0

raw patch · 16 files changed

+4134/−2152 lines, 16 filesdep ~okf-corePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: okf-core

API changes (from Hackage documentation)

- Okf.Cli: [registryRef] :: ProfileShowOptions -> !Maybe Text
- Okf.Cli.Config: [registry] :: ProfileSettings -> !Text
+ Okf.Cli: BuiltInRegistryOrigin :: ProfileSourceOrigin
+ Okf.Cli: Bundles :: BundlesOptions -> Command
+ Okf.Cli: BundlesOptions :: !Bool -> BundlesOptions
+ Okf.Cli: CompactTable :: RegistryTableMode
+ Okf.Cli: FreshnessCurrent :: !Text -> ProfileFreshness
+ Okf.Cli: FreshnessNotChecked :: ProfileFreshness
+ Okf.Cli: FreshnessOutdated :: !Text -> !Text -> ProfileFreshness
+ Okf.Cli: FreshnessUnavailable :: !Text -> ProfileFreshness
+ Okf.Cli: LegacyRegistryEnvironmentOrigin :: ProfileSourceOrigin
+ Okf.Cli: ProfileConfigOrigin :: !FilePath -> ProfileSourceOrigin
+ Okf.Cli: ProfileDiscoveryOrigin :: ![FilePath] -> ProfileSourceOrigin
+ Okf.Cli: ProfileSourceFailed :: !ProfileSourceLoadError -> ProfileSourceStatus
+ Okf.Cli: ProfileSourceLoaded :: !Int -> ProfileSourceStatus
+ Okf.Cli: ProfileSourceResolution :: !ResolvedProfileSource -> !ProfileSourceStatus -> ProfileSourceResolution
+ Okf.Cli: ProfileSources :: ProfileSourcesOptions -> ProfileCommand
+ Okf.Cli: ProfileSourcesOptions :: ![Text] -> !Bool -> !Bool -> !Bool -> ProfileSourcesOptions
+ Okf.Cli: Profiles :: ProfilesOptions -> Command
+ Okf.Cli: ProfilesOptions :: !Bool -> ProfilesOptions
+ Okf.Cli: RegistriesEnvironmentOrigin :: ProfileSourceOrigin
+ Okf.Cli: RegistryFlagOrigin :: ProfileSourceOrigin
+ Okf.Cli: ReleaseVersion :: !Int -> !Int -> !Int -> ReleaseVersion
+ Okf.Cli: ResolvedProfileSource :: !ProfileSource -> !ProfileSourceOrigin -> ResolvedProfileSource
+ Okf.Cli: SourcesOptions :: !Maybe FilePath -> SourcesOptions
+ Okf.Cli: TrustOptions :: !Maybe FilePath -> TrustOptions
+ Okf.Cli: WideTable :: RegistryTableMode
+ Okf.Cli: [checkLatest] :: ProfileSourcesOptions -> !Bool
+ Okf.Cli: [noLocal] :: ProfileShowOptions -> !Bool
+ Okf.Cli: [pickProfile] :: ValidateOptions -> !Bool
+ Okf.Cli: [profileSourceStatus] :: ProfileSourceResolution -> !ProfileSourceStatus
+ Okf.Cli: [registryRefs] :: ProfileShowOptions -> ![Text]
+ Okf.Cli: [resolvedProfileSource] :: ProfileSourceResolution -> !ResolvedProfileSource
+ Okf.Cli: [resolvedSource] :: ResolvedProfileSource -> !ProfileSource
+ Okf.Cli: [sourceOrigin] :: ResolvedProfileSource -> !ProfileSourceOrigin
+ Okf.Cli: [wide] :: ProfileListOptions -> !Bool
+ Okf.Cli: bundleListJson :: [(FilePath, [Text])] -> Value
+ Okf.Cli: data BundlesOptions
+ Okf.Cli: data ProfileFreshness
+ Okf.Cli: data ProfileSourceOrigin
+ Okf.Cli: data ProfileSourceResolution
+ Okf.Cli: data ProfileSourceStatus
+ Okf.Cli: data ProfileSourcesOptions
+ Okf.Cli: data ProfilesOptions
+ Okf.Cli: data RegistryTableMode
+ Okf.Cli: data ReleaseVersion
+ Okf.Cli: data ResolvedProfileSource
+ Okf.Cli: data SourcesOptions
+ Okf.Cli: data TrustOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.BundlesOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ProfileFreshness
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ProfileSourceOrigin
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ProfileSourceResolution
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ProfileSourceStatus
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ProfileSourcesOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ProfilesOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.RegistryTableMode
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ReleaseVersion
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ResolvedProfileSource
+ Okf.Cli: instance GHC.Classes.Ord Okf.Cli.ReleaseVersion
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.BundlesOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ProfileFreshness
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ProfileSourceOrigin
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ProfileSourceResolution
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ProfileSourceStatus
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ProfileSourcesOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ProfilesOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.RegistryTableMode
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ReleaseVersion
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ResolvedProfileSource
+ Okf.Cli: latestReleaseTag :: Text -> Maybe Text
+ Okf.Cli: observedIdPrefixes :: [Concept] -> [Text]
+ Okf.Cli: parseProfileRegistriesEnv :: Text -> Either Text [Text]
+ Okf.Cli: parseReleaseVersionTag :: Text -> Maybe ReleaseVersion
+ Okf.Cli: pinnedRegistryTag :: Text -> Maybe Text
+ Okf.Cli: profileDescriptorJson :: FilePath -> ProfileSpec -> Value
+ Okf.Cli: profileRegistriesEnvVar :: String
+ Okf.Cli: profileSourcesJson :: [ProfileSourceResolution] -> ProfileFreshness -> Value
+ Okf.Cli: registryListJson :: [ResolvedProfileSource] -> [SourcedProfile] -> Value
+ Okf.Cli: renderProfileSourceResolution :: [ProfileSourceResolution] -> ProfileFreshness -> Text
+ Okf.Cli: resolveEffectiveProfileSources :: [Text] -> Bool -> IO [ResolvedProfileSource]
+ Okf.Cli: resolveProfilePathWith :: FzfConfig -> Maybe FilePath -> IO FilePath
+ Okf.Cli: resolveProfileSources :: [Text] -> IO [ResolvedProfileSource]
+ Okf.Cli: selectSourcedProfile :: [SourcedProfile] -> Maybe Text -> Either Text SourcedProfile
+ Okf.Cli.BundleDiscovery: BundleDiscovery :: ![FilePath] -> ![FilePath] -> BundleDiscovery
+ Okf.Cli.BundleDiscovery: [bundlePaths] :: BundleDiscovery -> ![FilePath]
+ Okf.Cli.BundleDiscovery: [searchRoots] :: BundleDiscovery -> ![FilePath]
+ Okf.Cli.BundleDiscovery: bundleSearchRoots :: IO [FilePath]
+ Okf.Cli.BundleDiscovery: bundleSearchRootsEnvVar :: String
+ Okf.Cli.BundleDiscovery: data BundleDiscovery
+ Okf.Cli.BundleDiscovery: discoverAvailableBundles :: IO BundleDiscovery
+ Okf.Cli.BundleDiscovery: instance GHC.Classes.Eq Okf.Cli.BundleDiscovery.BundleDiscovery
+ Okf.Cli.BundleDiscovery: instance GHC.Internal.Show.Show Okf.Cli.BundleDiscovery.BundleDiscovery
+ Okf.Cli.BundleDiscovery: parseBundleSearchRoots :: String -> [FilePath]
+ Okf.Cli.Config: [registries] :: ProfileSettings -> ![Text]
+ Okf.Cli.Config: defaultProfileSettings :: ProfileSettings
+ Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.ConfigShapeWithLegacyProfiles
+ Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.LegacyProfileSettings
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.ConfigShapeWithLegacyProfiles
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.LegacyProfileSettings
+ Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.ConfigShapeWithLegacyProfiles
+ Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.LegacyProfileSettings
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.ConfigShapeWithLegacyProfiles
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.LegacyProfileSettings
+ Okf.Cli.Fzf.Selector: ProfileChosen :: !FilePath -> ProfileSelection
+ Okf.Cli.Fzf.Selector: ProfileNoCandidates :: ![FilePath] -> ProfileSelection
+ Okf.Cli.Fzf.Selector: ProfileSelectionCancelled :: ProfileSelection
+ Okf.Cli.Fzf.Selector: ProfileSelectionError :: !Text -> ProfileSelection
+ Okf.Cli.Fzf.Selector: ProfileSelectionUnavailable :: ProfileSelection
+ Okf.Cli.Fzf.Selector: data ProfileSelection
+ Okf.Cli.Fzf.Selector: instance GHC.Classes.Eq Okf.Cli.Fzf.Selector.ProfileSelection
+ Okf.Cli.Fzf.Selector: instance GHC.Internal.Show.Show Okf.Cli.Fzf.Selector.ProfileSelection
+ Okf.Cli.Fzf.Selector: profileCandidates :: [(FilePath, ProfileSpec)] -> [Candidate FilePath]
+ Okf.Cli.Fzf.Selector: profilePreviewCommand :: FilePath -> Text
+ Okf.Cli.Fzf.Selector: selectProfileDescriptor :: FzfConfig -> IO ProfileSelection
+ Okf.Cli.ProfileDiscovery: ProfileDiscovery :: ![FilePath] -> ![FilePath] -> ProfileDiscovery
+ Okf.Cli.ProfileDiscovery: [descriptorPaths] :: ProfileDiscovery -> ![FilePath]
+ Okf.Cli.ProfileDiscovery: [searchRoots] :: ProfileDiscovery -> ![FilePath]
+ Okf.Cli.ProfileDiscovery: data ProfileDiscovery
+ Okf.Cli.ProfileDiscovery: discoverAvailableProfiles :: IO ProfileDiscovery
+ Okf.Cli.ProfileDiscovery: instance GHC.Classes.Eq Okf.Cli.ProfileDiscovery.ProfileDiscovery
+ Okf.Cli.ProfileDiscovery: instance GHC.Internal.Show.Show Okf.Cli.ProfileDiscovery.ProfileDiscovery
+ Okf.Cli.ProfileDiscovery: parseProfileSearchRoots :: String -> [FilePath]
+ Okf.Cli.ProfileDiscovery: profileSearchRoots :: IO [FilePath]
+ Okf.Cli.ProfileDiscovery: profileSearchRootsEnvVar :: String
- Okf.Cli: ComputationsOptions :: !FilePath -> ComputationsOptions
+ Okf.Cli: ComputationsOptions :: !Maybe FilePath -> ComputationsOptions
- Okf.Cli: ConceptsOptions :: !FilePath -> ![Text] -> ![ConceptFilter] -> ![FieldSelector] -> ![FieldSelector] -> ![Text] -> !Maybe FilePath -> !Bool -> ConceptsOptions
+ Okf.Cli: ConceptsOptions :: !Maybe FilePath -> ![Text] -> ![ConceptFilter] -> ![FieldSelector] -> ![FieldSelector] -> ![Text] -> !Maybe FilePath -> !Bool -> ConceptsOptions
- Okf.Cli: GraphOptions :: !FilePath -> !Bool -> GraphOptions
+ Okf.Cli: GraphOptions :: !Maybe FilePath -> !Bool -> GraphOptions
- Okf.Cli: IdOptions :: !FilePath -> !FilePath -> !IdSub -> IdOptions
+ Okf.Cli: IdOptions :: !Maybe FilePath -> !FilePath -> !IdSub -> IdOptions
- Okf.Cli: IndexOptions :: !FilePath -> !Bool -> !Maybe Text -> IndexOptions
+ Okf.Cli: IndexOptions :: !Maybe FilePath -> !Bool -> !Maybe Text -> IndexOptions
- Okf.Cli: LogOptions :: !FilePath -> !Bool -> !Maybe Text -> !LogSub -> LogOptions
+ Okf.Cli: LogOptions :: !Maybe FilePath -> !Bool -> !Maybe Text -> !LogSub -> LogOptions
- Okf.Cli: ProfileDocumentOptions :: !Maybe Text -> !Maybe Text -> !Maybe FilePath -> !Maybe FilePath -> !Bool -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> ProfileDocumentOptions
+ Okf.Cli: ProfileDocumentOptions :: ![Text] -> !Maybe Text -> !Maybe FilePath -> !Bool -> !Maybe FilePath -> !Bool -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> ProfileDocumentOptions
- Okf.Cli: ProfileListOptions :: !Maybe Text -> !Bool -> ProfileListOptions
+ Okf.Cli: ProfileListOptions :: ![Text] -> !Bool -> !Bool -> !Bool -> ProfileListOptions
- Okf.Cli: ProfileShowOptions :: !Maybe Text -> !Maybe Text -> !Bool -> ProfileShowOptions
+ Okf.Cli: ProfileShowOptions :: ![Text] -> !Maybe Text -> !Bool -> !Bool -> ProfileShowOptions
- Okf.Cli: ValidateOptions :: !FilePath -> !Bool -> !Maybe FilePath -> !Bool -> !Bool -> ValidateOptions
+ Okf.Cli: ValidateOptions :: !Maybe FilePath -> !Bool -> !Maybe FilePath -> !Bool -> !Bool -> !Bool -> ValidateOptions
- Okf.Cli: [bundlePath] :: ValidateOptions -> !FilePath
+ Okf.Cli: [bundlePath] :: ValidateOptions -> !Maybe FilePath
- Okf.Cli: conceptReportJson :: [Text] -> [Concept] -> Value
+ Okf.Cli: conceptReportJson :: [Concept] -> Value
- Okf.Cli: renderRegistryTable :: [RegistryEntry] -> [Text]
+ Okf.Cli: renderRegistryTable :: RegistryTableMode -> [SourcedProfile] -> [Text]
- Okf.Cli.Config: ProfileSettings :: !Text -> ProfileSettings
+ Okf.Cli.Config: ProfileSettings :: ![Text] -> ProfileSettings

Files

CHANGELOG.md view
@@ -7,6 +7,64 @@  ## [Unreleased] +## [0.7.0.0] - 2026-08-18++### Added++- `okf profile sources` renders every effective source with its carried origin,+  load status, and profile count, or the same complete source objects with+  stable errors in JSON. It reports the built-in pinned tag without a network+  request; `--check-latest` is the explicit, graceful upstream comparison.+- `okf profiles` lists local descriptor paths and emits their metadata with+  `--json`; `OKF_PROFILE_ROOTS` supplies colon-separated search roots. Profile+  listings and named lookups append those descriptors after registry sources,+  with `--no-local` available for registry-only resolution.+- `okf validate --pick-profile` and input-free `okf profile document` use the+  optional `fzf` descriptor picker. Explicit paths and registry inputs stay+  non-interactive, with exit codes 1 for no candidates, 2 for unavailable+  selection, and 130 for cancellation.+- `--registry` is repeatable across `profile list`, `show`, and `document`;+  `OKF_PROFILE_REGISTRIES` accepts the same ordered list as a JSON array. Text+  listings add a `SOURCE` column, and `profile list --json` includes the source+  list plus a full provenance object on every profile.+- Multi-source listings report failed sources without hiding successful rows.+  Named profile lookup fails closed when any source failed and rejects an export+  published by several sources with a diagnostic naming every full reference.+- `okf bundles` prints discovered bundle paths without opening `fzf`. `--json`+  always emits `path` and adds a sorted `idPrefixes` array only when strict+  handles are observed in bundle frontmatter; it does not load a profile or+  invent metadata from a path.++### Changed++- `profiles.registries` replaces the singular configuration field. Files using+  the older `profiles.registry` spelling and the legacy+  `OKF_PROFILE_REGISTRY` environment variable still load as one-element lists.+- With no registry override, `okf profile list` now reports the ten OKF 0.2+  profiles in `mori://shinzui/okf-profiles` v0.10.0, including their+  descriptions, instead of the five undescribed OKF 0.1 profiles from v0.4.2.+  Compact output uses deterministic two-line rows no wider than 100 Unicode+  code points; `--wide` removes every source, export, name, and description cap.+- Every command that consumes an existing bundle accepts an omitted `BUNDLE`+  and selects one through the shared picker. Explicit paths remain fully+  non-interactive. `id next PREFIX` selects a bundle, while the existing+  `id next BUNDLE PREFIX` form is unchanged.+- **Breaking:** `okf concepts BUNDLE --json` now emits each selected concept's+  complete stored frontmatter object. The former+  `id`/`path`/`type`/`title`/`fields` envelope is gone; file-derived identity,+  Markdown bodies, and derived readings are not added. Filters still select the+  array entries in concept-ID order, while `--show` affects text columns only.+  The exported `conceptReportJson` API changes from `[Text] -> [Concept] ->+  Aeson.Value` to `[Concept] -> Aeson.Value`.+- Requires `okf-core ^>=0.7.0.0`, released alongside this version.++### Fixed++- Registry source failures are now plain, actionable summaries with no raw+  Dhall internals or ANSI styling. Missing paths, directories without+  `package.dhall`, hash mismatches, import failures, invalid Dhall, and+  unexpected evaluation failures remain distinct in text and JSON.+ ## [0.6.0.1] - 2026-08-16  ### Fixed
+ help/bundles.md view
@@ -0,0 +1,51 @@+BUNDLE DISCOVERY++  okf bundles lists the bundle paths okf can discover without opening a menu:++    okf bundles+    okf bundles --json++  Text output is one path per line, sorted and duplicate-free. Finding no+  bundles is successful and prints nothing. This command never invokes fzf and+  does not require a terminal, so it is safe in scripts and pipelines.++JSON OUTPUT++  --json emits a top-level array. Every entry has a path. When a bundle carries+  one or more strict document handles such as ADR-1 or BUG-3, idPrefixes lists+  their sorted, duplicate-free prefixes:++    [+      {"idPrefixes":["ADR","RFC"],"path":"docs/decisions"},+      {"path":"examples/ddd-ordering"}+    ]++  idPrefixes describes handles actually observed in top-level string+  frontmatter. It is not a declaration of profile policy. okf does not guess a+  prefix from a directory or filename, and listing does not load profile.dhall+  or access the network. A malformed value such as ADR-007 contributes nothing.++  If a discovered directory cannot later be walked as a bundle, it remains in+  the array with path only. An empty result is [].++SEARCH ROOTS++  Discovery searches the current directory by default. Set OKF_BUNDLE_ROOTS to+  a colon-separated list of directories to replace that default:++    OKF_BUNDLE_ROOTS=~/knowledge:~/work okf bundles++  A directory qualifies when it directly contains index.md, or a non-reserved+  Markdown file whose frontmatter declares a non-empty type. Once a directory+  qualifies, its subtree is pruned so a bundle and its subdirectories are not+  both listed. The scan descends at most four levels, skips hidden, symlinked,+  and common build directories, and silently skips unreadable or missing paths.++  A bundle whose top directory has neither index.md nor a concept of its own+  may appear as its first qualifying subdirectory. Use a more specific+  OKF_BUNDLE_ROOTS value or pass the intended BUNDLE explicitly when needed.++SEE ALSO++  okf help interactive   Choosing an omitted BUNDLE with fzf.+  okf help ids           The strict document-handle grammar.
help/concepts.md view
@@ -104,13 +104,16 @@  JSON OUTPUT -  okf concepts BUNDLE --show status --json+  okf concepts BUNDLE --json | jq '.[] | select(.status == "draft")' -  An array of objects with stable keys id, path, type, title, and fields. title-  is null when the concept has none, and fields is present even when no --show-  key was given, so a consumer never has to test for it. The values under fields-  are the raw frontmatter values rather than the display text a column shows: a-  list comes back as a list.+  The array contains one complete parsed frontmatter object per selected+  concept, in concept-ID order. Filters still choose which concepts enter the+  array. Each object preserves ordinary producer-defined keys and structured+  values, so the example reads status directly rather than through a wrapper.++  File-derived concept IDs and paths, Markdown bodies, derived readings, and a+  CLI-owned fields envelope are absent. --show adds columns to text output only;+  it never projects or limits JSON output.  SEE ALSO 
help/config.md view
@@ -65,7 +65,8 @@                                     own rather than replacing it.   agent.assist.*                    The same four fields for 'okf assist' alone.                                     These win over the shared defaults above.-  profiles.registry                 Registry okf profile reads by default.+  profiles.registries               Ordered registry list okf profile reads by+                                    default. An explicit [] selects none.    Providers are Dhall union values: Provider.Claude or Provider.Codex. Both are   supported; the corresponding CLI ('claude' or 'codex') must be installed.@@ -76,6 +77,8 @@    A configuration file written for an earlier okf, with an 'assist' block instead   of 'agent', still loads; its values are read as agent.assist.* .+  The older singular profiles.registry field also still loads and becomes a+  one-element profiles.registries list.  EXAMPLE @@ -102,6 +105,6 @@                 }             }         , profiles =-            { registry = "..."+            { registries = [ "..." ]             }         }
help/ids.md view
@@ -25,11 +25,13 @@  ALLOCATING AND LISTING -  okf id list BUNDLE --profile PROFILE.dhall-  okf id next BUNDLE PREFIX --profile PROFILE.dhall+  okf id list [BUNDLE] --profile PROFILE.dhall+  okf id next [BUNDLE] PREFIX --profile PROFILE.dhall    Both subcommands require a profile, because the profile is what declares the-  ID field and the allowed prefixes. Neither writes to the bundle.+  ID field and the allowed prefixes. Neither writes to the bundle. With one+  positional after id next, it is PREFIX and BUNDLE is selected interactively;+  with two, they keep the explicit BUNDLE PREFIX meaning.      okf id list decisions --profile profiles/decisions.dhall     ADR-1  decisions/use-markdown
help/interactive.md view
@@ -1,76 +1,91 @@ INTERACTIVE SELECTION -  okf show can ask you which bundle and which concept you mean instead of-  requiring you to type both.+  Commands that operate on an existing bundle can ask which bundle you mean+  when BUNDLE is omitted: +    okf validate                 pick a bundle, then validate it+    okf concepts --type Policy   pick a bundle, then list policies+    okf id next ADR --profile p  pick a bundle, then allocate ADR     okf show                     pick a bundle, then pick a concept-    okf show BUNDLE              pick a concept in BUNDLE-    okf show BUNDLE CONCEPT_ID   no menus; unchanged behavior -  Only okf show is interactive. Every other command still takes BUNDLE as a-  required argument.+  Bundle selection applies to validate, index, both log forms, graph, show,+  trust, sources, computations, concepts, and both id forms. Commands that do+  not consume an existing bundle do not open this menu. okf bundles lists the+  same candidates without opening a menu. +  Passing BUNDLE explicitly always bypasses bundle-picker detection and process+  spawning. Scripts and CI should pass it explicitly. okf show BUNDLE can still+  open its separate concept menu; okf show BUNDLE CONCEPT_ID opens no menus.+ REQUIREMENTS -  Interactive selection needs the fzf fuzzy finder on your PATH and a-  terminal. Without them, okf show tells you which argument to pass and-  exits 2. Nothing else in okf requires fzf.+  Interactive selection needs the fzf fuzzy finder on PATH and a terminal.+  Without them, an omitted BUNDLE exits 2 and tells you to pass it explicitly.+  No command requires fzf when BUNDLE is given, and okf bundles never uses it. -  fzf is read from the terminal device, not from standard input, so the-  menus still work inside a pipeline such as 'okf show | less'.+  fzf reads from the terminal device, not standard input, so a menu still works+  inside a pipeline such as 'okf validate | less'.  WHERE BUNDLES COME FROM    okf searches the current directory, four levels deep, for directories that-  look like a bundle: one holding an index.md, or one holding a Markdown file-  whose frontmatter declares a type. Once a directory qualifies, okf does not-  look inside it, so subdirectories of a bundle are not offered separately.+  look like a bundle: one holding index.md, or one holding a Markdown file whose+  frontmatter declares a type. Once a directory qualifies, okf does not look+  inside it, so subdirectories of a bundle are not offered separately.    Set OKF_BUNDLE_ROOTS to a colon-separated list of directories to search   somewhere else: -    OKF_BUNDLE_ROOTS=~/knowledge:~/work okf show+    OKF_BUNDLE_ROOTS=~/knowledge:~/work okf validate -  Directories that do not exist or cannot be read are skipped silently.+  Directories that do not exist or cannot be read are skipped silently. A+  bundle whose top directory holds neither index.md nor a concept document of+  its own is offered as its first qualifying subdirectory instead. Pass the+  bundle path explicitly when that happens. See 'okf help bundles' for the full+  discovery and JSON-listing contract. -  A bundle whose top directory holds neither an index.md nor a concept-  document of its own is offered as its first qualifying subdirectory-  instead. Pass the bundle path explicitly when that happens.+THE PROFILE MENU -THE CONCEPT MENU+  Profile descriptors have their own picker. It opens only when requested: -  Concepts are listed as three aligned columns -- concept ID, type, title ---  and the pane on the right previews the highlighted concept exactly as-  'okf show BUNDLE CONCEPT_ID' would print it.+    okf validate BUNDLE --pick-profile+    okf profile document -  The most recently modified concept is at the top, so whatever you were last-  working on is the first thing offered. The order comes from the modification-  time of each concept file; ties, such as a bundle whose files were all-  written by one checkout, fall back to concept ID. A concept whose file-  cannot be read is listed last rather than dropped.+  Bare `okf validate BUNDLE` still validates without a profile. Passing+  --profile PATH always bypasses profile-picker detection and process spawning.+  For `profile document`, an explicit --profile PATH, EXPORT, or --registry also+  bypasses the menu. -  Pass --sort id to list concepts alphabetically by concept ID instead, which-  is the order every other okf command prints them in:+  The menu searches for `.dhall` files that decode as profiles and shows path,+  profile name, and OKF version. Its preview is the same profile detail printed+  by `okf profile show`. Set OKF_PROFILE_ROOTS to a colon-separated root list;+  the default is the current directory. `okf profiles` lists the same paths+  without a menu, and an empty listing succeeds. -    okf show --sort id           alphabetical menu-    okf show --sort modified     most recently modified first (the default)+THE CONCEPT MENU -  --sort applies to the menu, so it does nothing when you name a CONCEPT_ID.-  An order okf does not know fails the command rather than falling back to-  the default.+  Concept selection remains specific to okf show. When CONCEPT_ID is omitted,+  concepts are listed as three aligned columns -- concept ID, type, title --+  and the pane on the right previews the highlighted concept exactly as+  'okf show BUNDLE CONCEPT_ID' would print it. -  Typing filters on the concept ID, type, and title together.+  The most recently modified concept is at the top. Ties fall back to concept+  ID, and a concept whose file cannot be read is listed last. Pass --sort id to+  list alphabetically or --sort modified to restore the default. Typing filters+  on the concept ID, type, and title together.  KEYS -  Type to filter, arrow keys or ctrl-n/ctrl-p to move, Enter to choose, Esc-  or ctrl-c to cancel. Cancelling exits with status 130 and prints nothing.+  Type to filter, arrow keys or ctrl-n/ctrl-p to move, Enter to choose, Esc or+  ctrl-c to cancel. Cancelling exits 130 and prints nothing.  EXIT STATUS -  0    a concept was printed-  1    nothing to choose from: no bundles found, or the bundle has no-       concepts-  2    no interactive selection available: fzf is missing, or there is no-       terminal+  0    the selected operation completed successfully+  1    no bundles, profiles, or concepts found, or the selected operation+       reported its ordinary failure+  2    no interactive selection available, or fzf failed   130  you cancelled with Esc or ctrl-c++  After selection, each command retains its existing success and failure+  semantics.
help/okf.md view
@@ -45,6 +45,7 @@  WHAT THE okf TOOL DOES +  bundles       List discoverable bundle paths without opening a menu.   validate      Check frontmatter conformance and, as an authoring aid, that                 links resolve and log.md files are well structured.   index         Generate progressive-disclosure index.md files per directory.@@ -74,6 +75,7 @@  SEE ALSO +  okf help bundles       Listing and discovering bundle paths.   okf help format        Bundle layout, concept IDs, frontmatter, and links.   okf help validation    What "conformant" means and how the tool checks it.   okf help profiles      Checking a bundle against house conventions.
help/profiles.md view
@@ -7,7 +7,10 @@  USAGE +  okf profiles+  okf profiles --json   okf validate BUNDLE --profile PROFILE.dhall+  okf validate BUNDLE --pick-profile   okf validate BUNDLE --profile PROFILE.dhall --profile-enforce  ADVISORY VS ENFORCED@@ -71,6 +74,26 @@   handle, `okf id list BUNDLE --profile PROFILE.dhall` to list allocations, and   `okf show BUNDLE ADR-7` to resolve one. +LOCAL DISCOVERY++  `okf profiles` searches the current directory, four levels deep, for `.dhall`+  files that decode as profile descriptors. It prints normalized paths in+  sorted, duplicate-free order. --json adds each profile's name, OKF version,+  and optional description. No candidates is a successful empty result.++  Set OKF_PROFILE_ROOTS to a colon-separated list of directories to search:++    OKF_PROFILE_ROOTS=docs/profiles:house/profiles okf profiles++  Missing, unreadable, hidden, symlinked, and common build directories are+  skipped. Discovery may resolve local imports and cached integrity-protected+  imports, but never makes a network request. A descriptor that cannot be read,+  evaluated without a fetch, or decoded is simply omitted.++  `okf profile list` and `show` append these local descriptors after the+  effective registry list. Pass --no-local to inspect registry sources only.+  A local descriptor's export is its filename without `.dhall`.+ REGISTRIES    You do not have to write a descriptor from scratch. A registry is any Dhall@@ -80,13 +103,18 @@   was found at. That path is the profile's export path.      okf profile list-    okf profile list --registry /path/to/okf-profiles+    okf profile list --registry /path/to/okf-profiles \+      --registry ./house-profiles+    okf profile sources     okf profile show postgresql --registry /path/to/okf-profiles -  A bare `okf profile` means `okf profile list`. Both subcommands accept-  --json. The EXPORT column reads "(root)" when the reference is itself a-  profile rather than a record of profiles; the ID FIELD column reads "-" when-  the profile declares no idField.+  A bare `okf profile` means `okf profile list`. list, show, and sources accept+  --json. Repeat --registry to merge registry sources; discovered local+  descriptors follow them unless --no-local is passed. Every profile uses two+  lines: a capped SOURCE/EXPORT/NAME/rule line of at most 100 characters and an+  indented description line. Pass --wide to list for every value in full. The+  EXPORT column reads "(root)" when a registry reference is itself a profile;+  ID FIELD reads "-" when the profile declares no idField.  GENERATING DOCUMENTATION @@ -101,8 +129,10 @@   and prints a one-line summary. --write without --out is an error, and so is   combining --profile with an EXPORT argument or --registry. -  Without --profile the profile comes from a registry export, using the same-  --registry precedence as list and show.+  With an EXPORT or --registry, the profile comes from the same effective+  registry-plus-local source list as list and show. With no profile input at+  all, the command opens a picker over discovered local descriptors. Pass an+  explicit --profile path or registry EXPORT in scripts and CI.    Each type page shows the EFFECTIVE rules for that type: the profile-wide   rules and the type's own, already merged. That is the difference from@@ -157,8 +187,8 @@   Descriptions are prose for humans -- okf never checks one against a bundle   and none can produce a deviation. -  `okf profile list` shows the profile's own description in a trailing-  DESCRIPTION column, reading "-" when it has none. `okf profile show` prints+  `okf profile list` shows the profile's own description on the indented line+  below its identity, reading "-" when it has none. `okf profile show` prints   the profile description under the name, one "  - key: prose" line per   frontmatter key, and a description line in each type block, all reading   "(none)" when absent. When a required key is missing from a concept, the@@ -388,12 +418,48 @@   hard profile-definition errors before any bundle is read.    A registry reference may be a path to a Dhall file, a directory holding-  package.dhall, or a Dhall expression such as a hash-pinned URL. Without-  --registry, okf uses OKF_PROFILE_REGISTRY, then profiles.registry from-  configuration, then the built-in default: the okf-profiles package pinned by-  tag and sha256 hash. Because it is pinned, Dhall caches it under-  ~/.cache/dhall after the first fetch, so later runs are offline. Pass-  --registry with a local checkout to be offline throughout.+  package.dhall, or a Dhall expression such as a hash-pinned URL. `okf profile+  sources` loads every effective source, prints its complete reference, carried+  origin, load status, and profile count, then prints these rules on every run.+  --json reports the same source objects and stable error categories+  structurally:++  Precedence, highest first:+    1. --registry flag (repeatable); the flag list replaces every other registry layer+    2. OKF_PROFILE_REGISTRIES (JSON array)+    3. OKF_PROFILE_REGISTRY (legacy single reference)+    4. profiles.registries in the effective config file+    5. built-in default when the decoded configuration has no profiles block+  Within a list, order is preserved and exact duplicates are dropped. Every source is+  enumerated; sources merge rather than replace. Local descriptors follow the winning+  registry list unless --no-local is passed. Survey commands report partial failure; named+  lookup fails closed.++  A configuration using the older profiles.registry spelling still loads as a+  one-element list.++  Listings report a failed source without hiding successful rows and exit 0+  when any profile was found. A named show or document lookup fails closed if+  any source failed. Two sources may list the same export, but using that export+  is ambiguous and fails with both full references; rerun with exactly one+  intended --registry REFERENCE.++  The built-in default is the okf-profiles package pinned by tag and sha256+  hash. Dhall caches it under ~/.cache/dhall after the first fetch, so later+  runs are offline. Pass --registry with a local checkout to be offline+  throughout.++  The built-in pin currently targets v0.10.0 and publishes ten OKF 0.2 profiles+  with descriptions. `okf profile sources` reports that version without network+  access. Pass --check-latest to that command for an explicit upstream tag+  comparison; a failed optional check is reported but does not change the source+  command's exit status. Repository maintainers refresh an explicitly reviewed+  tag and the offline conformance fixture together with:++    ./scripts/refresh-default-registry.sh TAG++  The script prints the matching defaultRegistryReference literal; it never+  chooses a tag automatically.    There is no install step. `okf profile show` closes with the two-line Dhall   snippet that consumes the profile; save it to a file and pass that file to
okf-cli.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.4 name:               okf-cli-version:            0.6.0.1+version:            0.7.0.0 synopsis:           Command-line interface for Open Knowledge Format bundles description:   okf-cli provides the @okf@ executable for working with Open Knowledge Format@@ -42,6 +42,7 @@     Okf.Cli     Okf.Cli.Agent.Config     Okf.Cli.Assist+    Okf.Cli.BundleDiscovery     Okf.Cli.Completions     Okf.Cli.Config     Okf.Cli.Fzf@@ -49,6 +50,7 @@     Okf.Cli.Help     Okf.Cli.Kit     Okf.Cli.Kit.Config+    Okf.Cli.ProfileDiscovery     Okf.Cli.Version    other-modules:   Paths_okf_cli@@ -69,7 +71,7 @@     , generic-lens          >=2.2      && <2.4     , githash               ^>=0.1     , lens                  ^>=5.3-    , okf-core              ^>=0.6.0.1+    , okf-core              ^>=0.7.0.0     , optparse-applicative  >=0.18     && <0.20     , process               >=1.6      && <1.7     , text                  ^>=2.1@@ -86,7 +88,7 @@     , directory     , filepath     , okf-cli-    , okf-core              ^>=0.6.0.1+    , okf-core              ^>=0.7.0.0     , optparse-applicative  >=0.18     , temporary     , text                  ^>=2.1
src/Okf/Cli.hs view
@@ -1,1990 +1,2736 @@ -- | Top-level CLI entry point for okf. module Okf.Cli-  ( Command (..),-    ComputationsOptions (..),-    ConceptsOptions (..),-    GraphOptions (..),-    IdOptions (..),-    IdSub (..),-    IndexOptions (..),-    ConfigCommand (..),-    LogAddOptions (..),-    LogOptions (..),-    LogSub (..),-    Options (..),-    ProfileCommand (..),-    ProfileDocumentOptions (..),-    ProfileListOptions (..),-    ProfileShowOptions (..),-    ShowOptions (..),-    ValidateOptions (..),-    computationReport,-    conceptReport,-    conceptReportJson,-    parserInfo,-    profileRegistryEnvVar,-    renderProfileDetail,-    renderProfileViolation,-    renderRegistryTable,-    runCli,-    runCommand,-    runLogAdd,-  )-where--import Control.Exception (IOException, try)-import Data.Aeson qualified as Aeson-import Data.Aeson.Key qualified as AesonKey-import Data.Aeson.KeyMap qualified as KeyMap-import Data.ByteString.Lazy qualified as LazyBytes-import Data.ByteString.Lazy.Char8 qualified as LazyByteString-import Data.Foldable (toList, traverse_)-import Data.List qualified as List-import Data.List.NonEmpty qualified as NonEmpty-import Data.Map.Strict qualified as Map-import Data.Maybe (mapMaybe)-import Data.Set qualified as Set-import Data.Text qualified as Text-import Data.Text.Encoding qualified as Text.Encoding-import Data.Text.IO qualified as Text.IO-import Data.Time (defaultTimeLocale, formatTime, getCurrentTime, utctDay)-import Okf.Actor (parseActor, renderActor)-import Okf.Bundle-import Okf.Cli.Agent.Config-  ( AgentCommandName (..),-    AgentField (..),-    AgentOverrides (..),-    ResolvedAgent,-    agentFieldEnvVar,-    allAgentCommands,-    noAgentOverrides,-    parseOkfEffort,-    parseOkfProvider,-    renderAgentResolution,-    resolveAgent,-  )-import Okf.Cli.Assist (AssistOptions, assistAgentOverrides, assistOptionsParser, handleAssistCommand)-import Okf.Cli.Completions (CompletionsShell, completionsParser, handleCompletions)-import Okf.Cli.Config-import Okf.Cli.Fzf (FzfConfig, detectFzfConfig)-import Okf.Cli.Fzf.Selector-  ( BundleSelection (..),-    ConceptOrder (..),-    ConceptSelection (..),-    bundleSearchRootsEnvVar,-    parseConceptOrder,-    renderConceptOrder,-    selectBundle,-    selectConcept,-  )-import Okf.Cli.Help (HelpCommand, handleHelpCommand, helpCommandParser)-import Okf.Cli.Kit (KitCommand, handleKitCommand, kitCommandParser)-import Okf.Cli.Version (appVersionWithGit)-import Okf.ConceptId-import Okf.Document-  ( Attester (..),-    ComputationSource (..),-    DocumentParseError (..),-    Executor (..),-    Frontmatter (..),-    Generated (..),-    OKFDocument (..),-    Parameter (..),-    Source (..),-    UsageWindow (..),-    attestedComputationType,-    body,-    effectiveUsageWindow,-    frontmatterLookup,-    renderStatus,-  )-import Okf.Graph (buildGraph)-import Okf.Index-import Okf.Log qualified as Log-import Okf.Path (PathReference (..), classifyPathReference)--- 'List' and 'Object' are 'Okf.Profile.Cardinality' constructors here; aeson's--- same-named 'Value' constructors are reached as 'Aeson.Object' and friends.-import Okf.Prelude hiding (List, Object)-import Okf.Profile-  ( Cardinality (..),-    CompiledProfile,-    FieldCondition (..),-    FieldFormat (..),-    FieldPath (..),-    FieldPathSegment (..),-    -- 'FrontmatterRules' and 'NestedRules' are deliberately absent: importing-    -- their field selectors would make @optional@ ambiguous against-    -- @optparse-applicative@'s, so this module reads all three presence lists-    -- through generic-lens labels instead.-    HandleReferenceRule (..),-    PathReferenceRule (..),-    ProfileDefinitionError (..),-    ProfileSpec (..),-    ProfileViolation (..),-    TypeRule (..),-    compileProfile,-    compiledProfileRulesForType,-    documentIdsInBundle,-    fieldRuleDescription,-    fieldRuleElementFields,-    fieldRuleObjectFields,-    loadProfileFile,-    nextDocumentId,-    parseDocumentId,-    profileFieldDescriptionForType,-    renderDocumentId,-    validateProfileVersion,-    validateProfileWith,-  )-import Okf.Profile.Documentation-  ( DocumentationError (..),-    DocumentationOptions (..),-    defaultDocumentationActor,-    defaultDocumentationOptions,-    renderProfileDocumentation,-  )-import Okf.Profile.Registry-  ( RegistryEntry (..),-    RegistryRef (..),-    findRegistryEntry,-    loadRegistry,-    renderRegistryRef,-    resolveRegistryRef,-    rootExportLabel,-  )-import Okf.Query-  ( ConceptFilter (..),-    FieldSelector (..),-    FilterProfileError (..),-    checkFiltersAgainstProfile,-    conceptFieldValues,-    filterConcepts,-    parseFieldEquals,-    parseFieldSelector,-    renderFieldSelector,-    renderFilter,-    renderFilterParseError,-    scalarText,-  )-import Okf.Trust-  ( Staleness (..),-    latestVerification,-    renderStaleness,-    renderTrustTier,-    staleness,-    trustTier,-  )-import Okf.Validation-import Options.Applicative-import System.Directory (createDirectoryIfMissing, doesFileExist)-import System.Environment (lookupEnv)-import System.Exit (ExitCode (..), exitFailure, exitWith)-import System.FilePath ((</>))-import System.FilePath qualified as FilePath-import System.IO (stderr)-import System.Process (readProcessWithExitCode)--data Command-  = Validate ValidateOptions-  | Index IndexOptions-  | Log LogOptions-  | GraphCommand GraphOptions-  | ShowConcept ShowOptions-  | Trust TrustOptions-  | Sources SourcesOptions-  | Computations ComputationsOptions-  | Concepts ConceptsOptions-  | Id IdOptions-  | Config ConfigCommand-  | Profile ProfileCommand-  | Kit KitCommand-  | Assist AssistOptions-  | Completions CompletionsShell-  | Help HelpCommand-  deriving stock (Show, Eq)--data ValidateOptions = ValidateOptions-  { bundlePath :: !FilePath,-    strictMode :: !Bool,-    profilePath :: !(Maybe FilePath),-    profileEnforce :: !Bool,-    logEnforce :: !Bool-  }-  deriving stock (Show, Eq)--data IndexOptions = IndexOptions-  { bundlePath :: !FilePath,-    write :: !Bool,-    okfVersion :: !(Maybe Text)-  }-  deriving stock (Show, Eq)--data LogOptions = LogOptions-  { bundlePath :: !FilePath,-    checkStale :: !Bool,-    sinceRef :: !(Maybe Text),-    logSub :: !LogSub-  }-  deriving stock (Show, Eq)--data LogSub-  = LogPreview-  | LogAdd LogAddOptions-  deriving stock (Show, Eq)--data LogAddOptions = LogAddOptions-  { conceptId :: !(Maybe Text),-    kind :: !Text,-    message :: !Text,-    date :: !(Maybe Text)-  }-  deriving stock (Show, Eq)--data GraphOptions = GraphOptions-  { bundlePath :: !FilePath,-    json :: !Bool-  }-  deriving stock (Show, Eq)--data ShowOptions = ShowOptions-  { bundlePath :: !(Maybe FilePath),-    conceptIdText :: !(Maybe Text),-    profilePath :: !(Maybe FilePath),-    computationOnly :: !Bool,-    -- | Order of the interactive concept menu; ignored when @CONCEPT_ID@ is-    -- given, because then there is no menu.-    conceptOrder :: !ConceptOrder-  }-  deriving stock (Show, Eq)--data TrustOptions = TrustOptions-  { bundlePath :: !FilePath-  }-  deriving stock (Show, Eq)--data SourcesOptions = SourcesOptions-  { bundlePath :: !FilePath-  }-  deriving stock (Show, Eq)--data ComputationsOptions = ComputationsOptions-  { bundlePath :: !FilePath-  }-  deriving stock (Show, Eq)--data ConceptsOptions = ConceptsOptions-  { bundlePath :: !FilePath,-    conceptTypes :: ![Text],-    fieldFilters :: ![ConceptFilter],-    presentFields :: ![FieldSelector],-    absentFields :: ![FieldSelector],-    showFields :: ![Text],-    profilePath :: !(Maybe FilePath),-    json :: !Bool-  }-  deriving stock (Show, Eq)--data IdOptions = IdOptions-  { bundlePath :: !FilePath,-    profilePath :: !FilePath,-    idSub :: !IdSub-  }-  deriving stock (Show, Eq)--data IdSub-  = IdNext !Text-  | IdList-  deriving stock (Show, Eq)--data ConfigCommand-  = ConfigShow-  | ConfigPath-  | ConfigInit !Bool-  | ConfigAgent-  deriving stock (Show, Eq)--data ProfileCommand-  = ProfileList ProfileListOptions-  | ProfileShow ProfileShowOptions-  | ProfileDocument ProfileDocumentOptions-  deriving stock (Show, Eq)--data ProfileListOptions = ProfileListOptions-  { registryRef :: !(Maybe Text),-    json :: !Bool-  }-  deriving stock (Show, Eq)--data ProfileShowOptions = ProfileShowOptions-  { registryRef :: !(Maybe Text),-    export :: !(Maybe Text),-    json :: !Bool-  }-  deriving stock (Show, Eq)--data ProfileDocumentOptions = ProfileDocumentOptions-  { registryRef :: !(Maybe Text),-    export :: !(Maybe Text),-    profilePath :: !(Maybe FilePath),-    outputPath :: !(Maybe FilePath),-    write :: !Bool,-    timestamp :: !(Maybe Text),-    generatedBy :: !(Maybe Text),-    generatedAt :: !(Maybe Text),-    okfVersion :: !(Maybe Text)-  }-  deriving stock (Show, Eq)--data Options = Options-  { cmd :: !Command-  }-  deriving stock (Show, Eq)--runCli :: IO ()-runCli = do-  Options {cmd} <- execParser parserInfo-  runCommand cmd--parserInfo :: ParserInfo Options-parserInfo =-  info-    (optionsParser <**> helper <**> versionOption)-    ( fullDesc-        <> progDesc "Validate, index, inspect, and graph Open Knowledge Format bundles"-        <> header "okf - Open Knowledge Format bundle tools"-    )--versionOption :: Parser (a -> a)-versionOption =-  infoOption-    (Text.unpack appVersionWithGit)-    (long "version" <> help "Show version information and exit")--optionsParser :: Parser Options-optionsParser = Options <$> commandParser--commandParser :: Parser Command-commandParser =-  hsubparser-    ( command "validate" (info (Validate <$> validateOptionsParser <**> helper) (progDesc "Validate an OKF bundle"))-        <> command "index" (info (Index <$> indexOptionsParser <**> helper) (progDesc "Preview or write generated index.md files"))-        <> command "log" (info (Log <$> logOptionsParser <**> helper) (progDesc "Preview and check log.md files"))-        <> command "graph" (info (GraphCommand <$> graphOptionsParser <**> helper) (progDesc "Print a bundle graph"))-        <> command "show" (info (ShowConcept <$> showOptionsParser <**> helper) (progDesc "Show one concept"))-        <> command "trust" (info (Trust <$> trustOptionsParser <**> helper) (progDesc "Report trust tiers, status, and staleness for every concept"))-        <> command "sources" (info (Sources <$> sourcesOptionsParser <**> helper) (progDesc "List the provenance recorded by each concept"))-        <> command "computations" (info (Computations <$> computationsOptionsParser <**> helper) (progDesc "List the attested computations a bundle declares"))-        <> command "concepts" (info (Concepts <$> conceptsOptionsParser <**> helper) (progDesc "List the concepts a bundle holds, with optional filters"))-        <> command "id" (info (Id <$> idOptionsParser <**> helper) (progDesc "Allocate and list document IDs"))-        <> command "config" (info (Config <$> configCommandParser <**> helper) (progDesc "Show and manage okf configuration"))-        <> command "profile" (info (Profile <$> profileCommandParser <**> helper) (progDesc "List and inspect profiles published by a registry"))-        <> command "kit" (info (Kit <$> kitCommandParser <**> helper) (progDesc "Install and manage agent skills and subagents"))-        <> command "assist" (info (Assist <$> assistOptionsParser <**> helper) (progDesc "Launch an interactive agent session with installed okf skills"))-        <> command "completions" (info (Completions <$> completionsParser <**> helper) (progDesc "Generate a shell completion script (bash, zsh, fish)"))-        <> command "help" (info (Help <$> helpCommandParser <**> helper) (progDesc "Show conceptual help topics"))-    )--validateOptionsParser :: Parser ValidateOptions-validateOptionsParser =-  ValidateOptions-    <$> bundleArgument-    <*> switch (long "strict" <> help "Require recommended authoring fields")-    <*> optional-      ( strOption-          ( long "profile"-              <> metavar "PROFILE"-              <> help "Path to a Dhall profile descriptor to check (advisory)"-          )-      )-    <*> switch (long "profile-enforce" <> help "Exit non-zero when profile checks find deviations")-    <*> switch (long "log-enforce" <> help "Exit non-zero when log staleness advisories are found")--indexOptionsParser :: Parser IndexOptions-indexOptionsParser =-  IndexOptions-    <$> bundleArgument-    <*> switch (long "write" <> help "Write generated index.md files instead of previewing")-    <*> optional-      ( strOption-          ( long "okf-version"-              <> metavar "MAJOR.MINOR"-              <> help "Declare the OKF version in the bundle root index"-          )-      )--logOptionsParser :: Parser LogOptions-logOptionsParser =-  logAddCommandParser <|> logPreviewOptionsParser--logPreviewOptionsParser :: Parser LogOptions-logPreviewOptionsParser =-  LogOptions-    <$> bundleArgument-    <*> switch (long "check-stale" <> help "Report concepts newer than their nearest log.md")-    <*> optional-      ( Text.pack-          <$> strOption-            ( long "since"-                <> metavar "GIT_REF"-                <> help "Report git drift since a ref (implemented in Milestone 6)"-            )-      )-    <*> pure LogPreview--logAddCommandParser :: Parser LogOptions-logAddCommandParser =-  hsubparser-    ( command-        "add"-        ( info-            (logAddOptionsToCommand <$> bundleArgument <*> logAddOptionsParser <**> helper)-            (progDesc "Append an entry to the nearest log.md")-        )-    )--logAddOptionsToCommand :: FilePath -> LogAddOptions -> LogOptions-logAddOptionsToCommand path addOptions =-  LogOptions-    { bundlePath = path,-      checkStale = False,-      sinceRef = Nothing,-      logSub = LogAdd addOptions-    }--logAddOptionsParser :: Parser LogAddOptions-logAddOptionsParser =-  LogAddOptions-    <$> optional (Text.pack <$> strArgument (metavar "CONCEPT_ID" <> help "Concept ID whose directory log.md should be updated"))-    <*> ( Text.pack-            <$> strOption-              ( long "kind"-                  <> metavar "KIND"-                  <> value "Update"-                  <> showDefault-                  <> help "Leading bold log entry kind"-              )-        )-    <*> ( Text.pack-            <$> strOption-              ( short 'm'-                  <> long "message"-                  <> metavar "MESSAGE"-                  <> help "Log entry message"-              )-        )-    <*> optional-      ( Text.pack-          <$> strOption-            ( long "date"-                <> metavar "YYYY-MM-DD"-                <> help "Entry date; defaults to today in UTC"-            )-      )--graphOptionsParser :: Parser GraphOptions-graphOptionsParser =-  GraphOptions-    <$> bundleArgument-    <*> switch (long "json" <> help "Print JSON graph output")---- | The @show@ command spells out its own bundle argument instead of reusing--- 'bundleArgument', because only here is the argument optional and the help--- text must say so.-showOptionsParser :: Parser ShowOptions-showOptionsParser =-  ShowOptions-    <$> optional-      ( strArgument-          ( metavar "BUNDLE"-              <> help "Path to an OKF bundle directory; omit to choose one interactively"-          )-      )-    <*> optional-      ( Text.pack-          <$> strArgument-            ( metavar "CONCEPT_ID"-                <> help "Concept ID such as tables/users; omit to choose one interactively"-            )-      )-    <*> optional-      ( strOption-          ( long "profile"-              <> metavar "PROFILE"-              <> help "Narrow document ID lookup to a profile's idField"-          )-      )-    <*> switch-      ( long "computation"-          <> help "Print only the computation and nothing else"-      )-    <*> option-      (maybeReader parseConceptOrder)-      ( long "sort"-          <> metavar "ORDER"-          <> value ByModifiedTime-          <> showDefaultWith (Text.unpack . renderConceptOrder)-          <> help "Order of the interactive concept menu: modified or id"-      )--idOptionsParser :: Parser IdOptions-idOptionsParser =-  hsubparser-    ( command-        "next"-        ( info-            ( IdOptions-                <$> bundleArgument-                <*> profileArgument-                <*> (IdNext . Text.pack <$> strArgument (metavar "PREFIX" <> help "Profile-declared document ID prefix"))-                  <**> helper-            )-            (progDesc "Print the next unused document ID")-        )-        <> command-          "list"-          ( info-              (IdOptions <$> bundleArgument <*> profileArgument <*> pure IdList <**> helper)-              (progDesc "List allocated document IDs")-          )-    )-  where-    profileArgument =-      strOption-        ( long "profile"-            <> metavar "PROFILE"-            <> help "Path to a Dhall profile descriptor declaring idField and idPrefix"-        )--configCommandParser :: Parser ConfigCommand-configCommandParser =-  hsubparser-    ( command "show" (info (pure ConfigShow) (progDesc "Print the effective configuration and its source"))-        <> command "path" (info (pure ConfigPath) (progDesc "Print the path the configuration was loaded from"))-        <> command-          "init"-          ( info-              (ConfigInit <$> switch (long "global" <> help "Write to ~/.config/okf/config.dhall instead of ./okf-config.dhall"))-              (progDesc "Write a commented example okf-config.dhall")-          )-        <> command-          "agent"-          ( info-              (pure ConfigAgent)-              (progDesc "Show the resolved agent settings and where each came from")-          )-    )-    <|> pure ConfigShow--profileCommandParser :: Parser ProfileCommand-profileCommandParser =-  hsubparser-    ( command-        "list"-        ( info-            (ProfileList <$> profileListOptionsParser <**> helper)-            (progDesc "List the profiles a registry publishes")-        )-        <> command-          "show"-          ( info-              (ProfileShow <$> profileShowOptionsParser <**> helper)-              (progDesc "Print one registry profile in full")-          )-        <> command-          "document"-          ( info-              (ProfileDocument <$> profileDocumentOptionsParser <**> helper)-              (progDesc "Generate an OKF bundle documenting a profile")-          )-    )-    <|> pure (ProfileList (ProfileListOptions Nothing False))--profileListOptionsParser :: Parser ProfileListOptions-profileListOptionsParser =-  ProfileListOptions-    <$> optional registryOption-    <*> jsonSwitch--profileShowOptionsParser :: Parser ProfileShowOptions-profileShowOptionsParser =-  ProfileShowOptions-    <$> optional registryOption-    <*> optional-      ( Text.pack-          <$> strArgument-            ( metavar "EXPORT"-                <> help "Dotted export path of the profile, as printed by `okf profile list`"-            )-      )-    <*> jsonSwitch--profileDocumentOptionsParser :: Parser ProfileDocumentOptions-profileDocumentOptionsParser =-  ProfileDocumentOptions-    <$> optional registryOption-    <*> optional-      ( Text.pack-          <$> strArgument-            ( metavar "EXPORT"-                <> help "Dotted export path of the profile, as printed by `okf profile list`"-            )-      )-    <*> optional-      ( strOption-          ( long "profile"-              <> metavar "PROFILE"-              <> help "Document a Dhall descriptor file directly instead of a registry export"-          )-      )-    <*> optional-      ( strOption-          ( long "out"-              <> metavar "DIR"-              <> help "Directory to write the generated bundle into"-          )-      )-    <*> switch-      ( long "write"-          <> help "Write the bundle to --out instead of previewing it on standard output"-      )-    <*> optional-      ( Text.pack-          <$> strOption-            ( long "timestamp"-                <> metavar "RFC3339"-                <> help "Value for the superseded v0.1 timestamp frontmatter key; omitted entirely when not given. Provenance is written as generated.by by default, so this is needed only when producing a v0.1 bundle."-            )-      )-    <*> optional-      ( Text.pack-          <$> strOption-            ( long "generated-by"-                <> metavar "ACTOR"-                <> help "Actor recorded in generated.by on every page: <producer>/<version>, human:<id>, or process:<id>. Defaults to process:okf-profile-document."-            )-      )-    <*> optional-      ( Text.pack-          <$> strOption-            ( long "generated-at"-                <> metavar "RFC3339"-                <> help "Timestamp recorded in generated.at. Omitted when not given, because generation never reads the clock."-            )-      )-    <*> optional-      ( strOption-          ( long "okf-version"-              <> metavar "MAJOR.MINOR"-              <> help "Declare the OKF version in the generated bundle's root index"-          )-      )--registryOption :: Parser Text-registryOption =-  Text.pack-    <$> strOption-      ( long "registry"-          <> metavar "REGISTRY"-          <> help "Dhall file, directory holding package.dhall, or Dhall expression publishing profiles"-      )--jsonSwitch :: Parser Bool-jsonSwitch = switch (long "json" <> help "Emit JSON instead of text")--bundleArgument :: Parser FilePath-bundleArgument =-  strArgument (metavar "BUNDLE" <> help "Path to an OKF bundle directory")--trustOptionsParser :: Parser TrustOptions-trustOptionsParser = TrustOptions <$> bundleArgument--sourcesOptionsParser :: Parser SourcesOptions-sourcesOptionsParser = SourcesOptions <$> bundleArgument--computationsOptionsParser :: Parser ComputationsOptions-computationsOptionsParser = ComputationsOptions <$> bundleArgument---- | The bundle argument is required and this command never launches @fzf@: per--- @docs\/adr\/2-interactive-bundle-and-concept-selection.md@, a convenience that--- can make a scripted invocation behave differently is not a convenience.------ A malformed filter is rejected here rather than in 'runConcepts', so a typo--- fails before the bundle is walked. 'eitherReader' rather than 'maybeReader' so--- that the message a user sees is ours: optparse-applicative prints--- @option --where: \<message\>@ and exits 1, which is what every other flag in--- this tool does with a bad value.-conceptsOptionsParser :: Parser ConceptsOptions-conceptsOptionsParser =-  ConceptsOptions-    <$> bundleArgument-    <*> many-      ( Text.pack-          <$> strOption-            ( long "type"-                <> metavar "TYPE"-                <> help "Keep concepts whose type is exactly TYPE; repeat for any-of"-            )-      )-    <*> many-      ( option-          (eitherReader (first (Text.unpack . renderFilterParseError) . parseFieldEquals . Text.pack))-          ( long "where"-              <> metavar "KEY=VALUE"-              <> help-                "Keep concepts whose frontmatter KEY holds VALUE; KEY may be nested one level (reviews.outcome). Repeat the same key for any-of, different keys for all-of"-          )-      )-    <*> many (option fieldSelectorReader (long "has" <> metavar "KEY" <> help "Keep concepts that carry KEY at all"))-    <*> many (option fieldSelectorReader (long "missing" <> metavar "KEY" <> help "Keep concepts that do not carry KEY"))-    <*> many-      ( Text.pack-          <$> strOption-            ( long "show"-                <> metavar "KEY"-                <> help "Add a column displaying KEY; repeat for more columns"-            )-      )-    <*> optional-      ( strOption-          ( long "profile"-              <> metavar "PROFILE"-              <> help "Path to a Dhall profile descriptor; reject a filter no concept could satisfy"-          )-      )-    <*> jsonSwitch-  where-    fieldSelectorReader =-      eitherReader (first (Text.unpack . renderFilterParseError) . parseFieldSelector . Text.pack)--runCommand :: Command -> IO ()-runCommand = \case-  Validate options -> runValidate options-  Index options -> runIndex options-  Log options -> runLog options-  GraphCommand options -> runGraph options-  ShowConcept options -> runShow options-  Trust options -> runTrust options-  Sources options -> runSources options-  Computations options -> runComputations options-  Concepts options -> runConcepts options-  Id options -> runId options-  Config configCommand -> runConfig configCommand-  Profile profileCommand -> runProfile profileCommand-  Kit kitCommand -> do-    config <- loadConfigOrDie-    handleKitCommand config kitCommand-  Assist assistOptions -> do-    config <- loadConfigOrDie-    resolved <- resolveAgentOrDie AgentCmdAssist (assistAgentOverrides assistOptions)-    handleAssistCommand config resolved assistOptions-  Completions shell -> handleCompletions shell-  Help helpCommand -> handleHelpCommand helpCommand--runConfig :: ConfigCommand -> IO ()-runConfig = \case-  ConfigShow -> do-    (config, configSource) <- loadConfigWithSourceOrDie-    Text.IO.putStrLn ("source: " <> renderConfigSource configSource)-    Text.IO.putStr (renderConfig config)-  ConfigPath -> do-    configSource <- findConfigSource-    Text.IO.putStrLn (renderConfigSource configSource)-  ConfigInit global -> do-    target <- if global then xdgConfigPath else projectConfigPath-    exists <- doesFileExist target-    if exists-      then dieText ("Refusing to overwrite existing config: " <> Text.pack target)-      else do-        createDirectoryIfMissing True (FilePath.takeDirectory target)-        Text.IO.writeFile target exampleConfigText-        Text.IO.putStrLn ("Wrote " <> Text.pack target)-  -- No flags of its own, so what it prints is what `okf assist` would use when-  -- invoked with none.-  ConfigAgent -> do-    resolutions <--      traverse-        (\agentCommand -> (agentCommand,) <$> resolveAgentOrDie agentCommand noAgentOverrides)-        allAgentCommands-    Text.IO.putStr (renderAgentResolution resolutions)--loadConfigOrDie :: IO OkfConfig-loadConfigOrDie = fst <$> loadConfigWithSourceOrDie--loadConfigWithSourceOrDie :: IO (OkfConfig, ConfigSource)-loadConfigWithSourceOrDie = do-  result <- loadOkfConfig-  case result of-    Left err -> dieText ("Failed to load config: " <> err)-    Right loaded -> pure loaded---- | Resolve one agent command's settings across flags, environment, and both--- configuration scopes, terminating with a message if any input is unusable.-resolveAgentOrDie :: AgentCommandName -> AgentOverrides -> IO ResolvedAgent-resolveAgentOrDie agentCommand flagOverrides = do-  envOverrides <- readAgentEnvOverrides-  scopes <- loadAgentScopes-  case scopes of-    Left err -> dieText err-    Right (localAgent, globalAgent) ->-      pure (resolveAgent agentCommand flagOverrides envOverrides localAgent globalAgent)---- | Read the @OKF_AGENT_*@ variables. A variable set to blank is treated as--- unset, matching how a blank configuration key is treated, so clearing one--- with @OKF_AGENT_MODEL=@ works rather than failing to parse.-readAgentEnvOverrides :: IO AgentOverrides-readAgentEnvOverrides = do-  providerValue <- lookupParsedEnv ProviderField parseOkfProvider-  modelValue <- lookupEnvText ModelField-  effortValue <- lookupParsedEnv EffortField parseOkfEffort-  systemPromptValue <- lookupEnvText SystemPromptField-  pure-    AgentOverrides-      { provider = providerValue,-        model = modelValue,-        effort = effortValue,-        systemPrompt = systemPromptValue-      }-  where-    lookupEnvText agentField = do-      raw <- lookupEnv (agentFieldEnvVar agentField)-      pure (nonBlankEnv =<< raw)--    lookupParsedEnv agentField parse = do-      raw <- lookupEnv (agentFieldEnvVar agentField)-      case nonBlankEnv =<< raw of-        Nothing -> pure Nothing-        Just rawValue ->-          case parse rawValue of-            -- Name the variable, so a user with four of them set knows which to fix.-            Left message -> dieText (Text.pack (agentFieldEnvVar agentField) <> ": " <> message)-            Right parsed -> pure (Just parsed)--    nonBlankEnv raw =-      let stripped = Text.strip (Text.pack raw)-       in if Text.null stripped then Nothing else Just stripped---- | Environment override for the registry @okf profile@ reads.-profileRegistryEnvVar :: String-profileRegistryEnvVar = "OKF_PROFILE_REGISTRY"--runProfile :: ProfileCommand -> IO ()-runProfile = \case-  ProfileList options -> runProfileList options-  ProfileShow options -> runProfileShow options-  ProfileDocument options -> runProfileDocument options---- | Registry reference precedence: @--registry@, then 'profileRegistryEnvVar',--- then configuration (which falls back to the built-in default). Configuration--- is read only when it is actually needed, so a broken @okf-config.dhall@--- cannot stop @okf profile list --registry ./somewhere.dhall@.-resolveRegistryReference :: Maybe Text -> IO Text-resolveRegistryReference (Just explicit) = pure explicit-resolveRegistryReference Nothing = do-  fromEnvironment <- lookupEnv profileRegistryEnvVar-  case fromEnvironment of-    Just fromShell | not (null fromShell) -> pure (Text.pack fromShell)-    _ -> do-      OkfConfig {profiles = ProfileSettings {registry}} <- loadConfigOrDie-      pure registry---- | Resolve, evaluate, and enumerate a registry, or exit 1 explaining why not.--- The reference is returned in the form the user gave it, for messages, along--- with the resolved reference, which is what @show@ quotes back as Dhall.-loadRegistryOrDie :: Maybe Text -> IO (Text, RegistryRef, [RegistryEntry])-loadRegistryOrDie explicit = do-  reference <- resolveRegistryReference explicit-  ref <- resolveRegistryRef reference-  loaded <- loadRegistry ref-  case loaded of-    Left err -> dieText (renderRegistryLoadError reference err)-    Right [] -> dieText ("No profiles found in registry " <> reference)-    Right entries -> pure (reference, ref, entries)---- | A load failure is usually a mistyped path or a missing network, so say what--- a reference may be and how to work offline.-renderRegistryLoadError :: Text -> Text -> Text-renderRegistryLoadError reference err =-  Text.unlines-    [ "Failed to load profile registry " <> reference <> ": " <> err,-      "A registry reference may be a path to a Dhall file, a directory holding package.dhall, or a",-      "Dhall expression such as a hash-pinned URL. Remote references need network access on first",-      "use; pass --registry with a local checkout to work offline."-    ]--runProfileList :: ProfileListOptions -> IO ()-runProfileList ProfileListOptions {registryRef, json} = do-  (reference, _ref, entries) <- loadRegistryOrDie registryRef-  if json-    then LazyByteString.putStrLn (Aeson.encode (registryListJson reference entries))-    else traverse_ Text.IO.putStrLn (renderRegistryTable entries)--registryListJson :: Text -> [RegistryEntry] -> Aeson.Value-registryListJson reference entries =-  Aeson.object-    [ "registry" Aeson..= reference,-      "profiles"-        Aeson..= [ Aeson.object-                     [ "export" Aeson..= export,-                       "profile" Aeson..= spec-                     ]-                 | RegistryEntry {export, spec} <- entries-                 ]-    ]---- | An aligned table: a header row plus one row per profile, columns padded to--- their widest value. Pure so it can be tested without evaluating any Dhall.------ @DESCRIPTION@ comes last so the existing columns keep their positions and a--- long description cannot push anything off the right edge. Nothing follows it,--- so it is never padded; an absent description reads @-@, matching @ID FIELD@.-renderRegistryTable :: [RegistryEntry] -> [Text]-renderRegistryTable entries =-  map renderRow rows-  where-    headerRow = ["EXPORT", "NAME", "OKF", "TYPES", "ID FIELD", "DESCRIPTION"]-    entryRow-      RegistryEntry-        { export = exportPath,-          spec = ProfileSpec {name, description, okfVersion, idField, types = typeRules}-        } =-        [ displayExport exportPath,-          name,-          okfVersion,-          Text.pack (show (length typeRules)),-          fromMaybe "-" idField,-          fromMaybe "-" description-        ]--    rows = headerRow : map entryRow entries--    -- One padder per column, in order; the last column is left as it is.-    padders = [padRight, padRight, padLeft, padLeft, padRight, \_ cell -> cell]-    widths = [maximum (0 : map (Text.length . (!! column)) rows) | column <- [0 .. 5]]--    renderRow cells = Text.intercalate "  " (zipWith3 id padders widths cells)--    padRight width cell = cell <> Text.replicate (max 0 (width - Text.length cell)) " "-    padLeft width cell = Text.replicate (max 0 (width - Text.length cell)) " " <> cell---- | An entry found at the registry root has no export path of its own.-displayExport :: Text -> Text-displayExport exportPath-  | Text.null exportPath = rootExportLabel-  | otherwise = exportPath--runProfileShow :: ProfileShowOptions -> IO ()-runProfileShow ProfileShowOptions {registryRef, export = requestedExport, json} = do-  (reference, ref, entries) <- loadRegistryOrDie registryRef-  RegistryEntry {export = foundExport, spec} <- selectEntry reference entries requestedExport-  if json-    then LazyByteString.putStrLn (Aeson.encode spec)-    else do-      traverse_ Text.IO.putStrLn (renderProfileDetail foundExport spec)-      traverse_ Text.IO.putStrLn (renderProfileUsage ref foundExport)---- | Pick the profile to show. With no @EXPORT@ argument a single-profile--- registry needs no disambiguation; otherwise the available exports are listed,--- which is also what an unknown export reports.-selectEntry :: Text -> [RegistryEntry] -> Maybe Text -> IO RegistryEntry-selectEntry reference entries = \case-  Nothing -> case entries of-    [single] -> pure single-    _ ->-      dieText-        ( "Registry "-            <> reference-            <> " publishes more than one profile; name one.\n"-            <> availableExports entries-        )-  Just requested -> case findRegistryEntry requested entries of-    Just entry -> pure entry-    Nothing ->-      dieText-        ( "No profile named "-            <> requested-            <> " in registry "-            <> reference-            <> "\n"-            <> availableExports entries-        )-  where-    availableExports found =-      "Available exports: "-        <> Text.intercalate ", " [displayExport exportPath | RegistryEntry {export = exportPath} <- found]---- | Generate an OKF bundle documenting a profile.------ Preview by default: nothing is written unless both @--out DIR@ and @--write@--- are given. The write rules are recorded in--- @docs\/adr\/6-generated-profile-documentation.md@: the command overwrites--- exactly the files it generates, never deletes, and reports concepts already--- in the destination that this run did not generate.-runProfileDocument :: ProfileDocumentOptions -> IO ()-runProfileDocument-  ProfileDocumentOptions-    { registryRef,-      export = requestedExport,-      profilePath,-      outputPath,-      write,-      timestamp,-      generatedBy,-      generatedAt,-      okfVersion-    } = do-    when (isJust profilePath && (isJust requestedExport || isJust registryRef)) $-      dieText "Pass either --profile PATH or an EXPORT argument, not both."-    when (write && isNothing outputPath) $-      dieText "--write needs a destination; pass --out DIR."-    -- Parse the requested version before anything is written, so a malformed-    -- one fails without leaving a half-generated bundle behind.-    versionOverride <- traverse requestedVersion okfVersion-    compiled <- resolveCompiledProfile-    concepts <- case renderProfileDocumentation documentationOptions compiled of-      Left documentationError -> dieText (renderDocumentationError documentationError)-      Right rendered -> pure rendered-    case (write, outputPath) of-      (True, Just destination) -> writeProfileDocumentation versionOverride destination concepts-      _ -> previewProfileDocumentation outputPath concepts-    where-      -- Same diagnostic as @okf index --okf-version@, so the two commands-      -- reject a malformed version identically.-      requestedVersion rawVersion =-        case parseOkfVersion rawVersion of-          Just version -> pure version-          Nothing -> dieText ("Not an OKF version of the form MAJOR.MINOR: " <> rawVersion)--      documentationOptions =-        DocumentationOptions-          { rootConceptId = rootConceptId defaultDocumentationOptions,-            typeDirectory = typeDirectory defaultDocumentationOptions,-            timestamp = timestamp,-            -- 'parseActor' is total, so @--generated-by Nadeem@ is preserved-            -- verbatim as an unclassified actor rather than rejected:-            -- specification §11 forbids failing on a malformed optional field.-            generated =-              Just-                ( Generated-                    (maybe defaultDocumentationActor parseActor generatedBy)-                    generatedAt-                )-          }--      resolveCompiledProfile = do-        (label, spec) <- case profilePath of-          Just path -> do-            loadedSpec <- loadProfileOrExit path-            pure (Text.pack path, loadedSpec)-          Nothing -> do-            (reference, _ref, entries) <- loadRegistryOrDie registryRef-            RegistryEntry {export = foundExport, spec} <--              selectEntry reference entries requestedExport-            pure (displayExport foundExport, spec)-        compileProfileOrExit label spec---- | Print every file the command would generate, in the same shape--- @okf index@ previews its own output, then say what would happen on--- @--write@. Touches nothing.-previewProfileDocumentation :: Maybe FilePath -> [Concept] -> IO ()-previewProfileDocumentation destination concepts = do-  mapM_-    (\concept -> renderIndexPreview (conceptSourcePath concept, serializeConcept concept))-    concepts-  Text.IO.putStrLn summary-  where-    summary =-      case destination of-        Just directory ->-          "(preview only; pass --write to write these "-            <> countPhrase (length concepts) "file"-            <> " to "-            <> Text.pack directory-            <> ")"-        Nothing ->-          "(preview only; pass --out DIR --write to write these "-            <> countPhrase (length concepts) "file"-            <> ")"---- | Write the generated bundle and regenerate the destination's index files.------ Overwrites exactly the concepts it generated and never deletes, so a--- destination holding pages from an earlier profile keeps them. Those are--- reported rather than silently left to rot.------ A 'Just' version override declares that OKF version in the destination's root--- index; 'Nothing' preserves whatever declaration is already there, so--- regenerating into a directory that declares a version cannot strip it.-writeProfileDocumentation :: Maybe OkfVersion -> FilePath -> [Concept] -> IO ()-writeProfileDocumentation versionOverride destination concepts = do-  -- A destination that does not exist yet walks as an IO error, which here-  -- means "no pre-existing concepts" rather than a failure: writeBundle-  -- creates the directory.-  existing <- walkBundle destination-  let generated = Set.fromList (map conceptIdOf concepts)-      stale =-        [ conceptId-        | concept <- either (const []) Prelude.id existing,-          let conceptId = conceptIdOf concept,-          not (Set.member conceptId generated)-        ]-  writeBundle destination concepts-  indexes <- renderBundleIndexesWith versionOverride destination-  indexCount <- case indexes of-    Left bundleError -> dieText (renderBundleError bundleError)-    -- Count distinct paths: 'renderBundleIndexes' yields the bundle root twice,-    -- once as "" and once as ".", which write the same file.-    Right rendered -> pure (length (List.nub (map (FilePath.normalise . fst) rendered)))-  written <- writeBundleIndexesWith versionOverride destination-  case written of-    Left bundleError -> dieText (renderBundleError bundleError)-    Right () -> pure ()-  Text.IO.putStrLn-    ( "Wrote "-        <> countPhrase (length concepts) "concept"-        <> " and "-        <> countPhrase indexCount "index.md file"-        <> " to "-        <> Text.pack destination-    )-  unless (null stale) $-    Text.IO.putStrLn-      ( "Note: "-          <> Text.pack destination-          <> " also contains "-          <> countPhrase (length stale) "concept"-          <> " this profile did not generate ("-          <> Text.intercalate ", " (map renderConceptId stale)-          <> "). Left untouched; delete them if their type rules were removed."-      )---- | @1 concept@ but @2 concepts@, so summary lines read as English.-countPhrase :: Int -> Text -> Text-countPhrase count noun =-  Text.pack (show count) <> " " <> noun <> (if count == 1 then "" else "s")--renderDocumentationError :: DocumentationError -> Text-renderDocumentationError = \case-  InvalidRootConceptId raw err -> renderConceptIdError raw err-  InvalidTypeDirectory raw err -> renderConceptIdError raw err---- | One profile's complete rule set. Every optional field prints as @(none)@--- rather than being omitted, so the output shape does not change between--- profiles and stays reliable to eyeball or grep. Type rules print in the order--- the profile declares them, since that order is the author's.-renderProfileDetail :: Text -> ProfileSpec -> [Text]-renderProfileDetail-  exportPath-  ProfileSpec-    { name,-      description,-      okfVersion,-      frontmatter,-      allowUnknownTypes,-      allowUnknownFields,-      idField,-      requireBundleVersion,-      types = typeRules-    } =-    [ "export: " <> displayExport exportPath,-      "name: " <> name,-      "description: " <> renderOptional description,-      "okfVersion: " <> okfVersion,-      "requireBundleVersion: " <> renderOptional requireBundleVersion,-      "allowUnknownTypes: " <> renderFlag allowUnknownTypes,-      "allowUnknownFields: " <> renderFlag allowUnknownFields,-      "idField: " <> renderOptional idField-    ]-      <> renderPresenceLists "" frontmatter-      <> concatMap renderTypeRule typeRules-    where-      -- The three presence lists always print together and in the same order, at-      -- profile scope and under every type rule, so the effective policy for one-      -- key is readable in one place.-      renderPresenceLists indent rules =-        renderFieldRules indent "frontmatter.required" (rules ^. #required)-          <> renderFieldRules indent "frontmatter.recommended" (rules ^. #recommended)-          <> renderFieldRules indent "frontmatter.optional" (rules ^. #optional)--      -- A field's prose cannot share a comma-joined line with its neighbours, so-      -- a non-empty list becomes a headed block. An empty list keeps the-      -- single-line @(none)@ form the other optional fields use.-      renderFieldRules indent label [] = [indent <> label <> ": " <> renderList []]-      renderFieldRules indent label rules =-        (indent <> label <> ":")-          : concatMap (renderFieldRule indent) rules--      renderFieldRule indent rule =-        [ indent <> "  - " <> rule ^. #field <> ": " <> renderOptional (rule ^. #description),-          indent <> "    allowedValues: " <> renderVocabulary (rule ^. #allowedValues),-          indent <> "    cardinality: " <> renderCardinality (rule ^. #cardinality),-          indent <> "    format: " <> maybe "(none)" renderFieldFormat (rule ^. #format),-          indent <> "    reference: " <> maybe "(none)" renderHandleReferenceRule (rule ^. #reference),-          indent <> "    path: " <> maybe "(none)" renderPathReferenceRule (rule ^. #path),-          indent <> "    when: " <> maybe "(none)" renderCondition (rule ^. #when)-        ]-          -- The two nested shapes print together and in this order, matching-          -- 'Okf.Profile.Documentation.renderFieldRule', which is the other-          -- renderer of the same rule. @objectFields@ constrains the record that-          -- /is/ the value, so it reaches @executor.resource@; @elementFields@-          -- constrains the members of each element of a list, so it reaches-          -- @sources[].resource@. A reader comparing the two should not have to-          -- scroll past seven scalar lines to find the second one.-          <> renderNestedBlock indent "objectFields" (rule ^. #objectFields)-          <> renderNestedBlock indent "elementFields" (rule ^. #elementFields)--      renderNestedBlock indent label = \case-        Nothing -> [indent <> "    " <> label <> ": (none)"]-        Just nestedRules ->-          [indent <> "    " <> label <> ":"]-            <> renderNestedFieldRules (indent <> "      ") "required" (nestedRules ^. #required)-            <> renderNestedFieldRules (indent <> "      ") "recommended" (nestedRules ^. #recommended)-            <> renderNestedFieldRules (indent <> "      ") "optional" (nestedRules ^. #optional)--      renderNestedFieldRules indent label [] = [indent <> label <> ": " <> renderList []]-      renderNestedFieldRules indent label rules =-        (indent <> label <> ":") : concatMap (renderNestedFieldRule indent) rules--      renderNestedFieldRule indent rule =-        [ indent <> "  - " <> rule ^. #field <> ": " <> renderOptional (rule ^. #description),-          indent <> "    allowedValues: " <> renderVocabulary (rule ^. #allowedValues),-          indent <> "    cardinality: " <> renderCardinality (rule ^. #cardinality),-          indent <> "    format: " <> maybe "(none)" renderFieldFormat (rule ^. #format),-          indent <> "    path: " <> maybe "(none)" renderPathReferenceRule (rule ^. #path),-          indent <> "    when: " <> maybe "(none)" renderCondition (rule ^. #when)-        ]--      renderTypeRule-        TypeRule-          { type_ = ruleType,-            description = ruleDescription,-            frontmatter = typeFrontmatter,-            pathPattern,-            resourceScheme,-            requireSchemaSection,-            schemaColumns,-            idPrefix-          } =-          [ "",-            "type: " <> ruleType,-            "  description: " <> renderOptional ruleDescription-          ]-            <> renderPresenceLists "  " typeFrontmatter-            <> [ "  pathPattern: " <> renderOptional pathPattern,-                 "  resourceScheme: " <> renderOptional resourceScheme,-                 "  requireSchemaSection: " <> renderFlag requireSchemaSection,-                 "  schemaColumns: " <> renderList schemaColumns,-                 "  idPrefix: " <> renderOptional idPrefix-               ]--      renderFlag True = "true"-      renderFlag False = "false"-      renderOptional = fromMaybe "(none)"-      renderList [] = "(none)"-      renderList values = Text.intercalate ", " values-      renderVocabulary [] = "(any)"-      renderVocabulary values = Text.intercalate ", " values-      renderCondition FieldCondition {field = sourceField, hasValue} =-        sourceField <> " in [" <> Text.intercalate ", " hasValue <> "]"---- | The two-line descriptor a user writes to consume the profile with--- @okf validate --profile@. The reference is quoted in Dhall import syntax, not--- as the user typed it: Dhall only accepts a path that starts with @.\/@,--- @..\/@, @~\/@, or @\/@, so a bare relative path is prefixed to stay--- copy-pasteable.-renderProfileUsage :: RegistryRef -> Text -> [Text]-renderProfileUsage ref exportPath =-  [ "",-    "Use it with:",-    "  let registry = " <> dhallImport ref,-    "  in  registry" <> selector-  ]-  where-    selector-      | Text.null exportPath = ""-      | otherwise = "." <> exportPath--    dhallImport (RegistryExpression expression) = expression-    dhallImport (RegistryFile path)-      | any (`Text.isPrefixOf` rendered) ["./", "../", "~/", "/"] = rendered-      | otherwise = "./" <> rendered-      where-        rendered = renderRegistryRef (RegistryFile path)--runValidate :: ValidateOptions -> IO ()-runValidate ValidateOptions {bundlePath, strictMode, profilePath, profileEnforce, logEnforce} = do-  concepts <- loadBundleOrExit bundlePath-  inventory <- loadBundleInventoryOrExit bundlePath-  logs <- loadLogsOrExit bundlePath-  declaration <- loadBundleVersionOrExit bundlePath-  let coreProfile = if strictMode then StrictAuthoring else PermissiveConformance-      coreErrors = validateBundle coreProfile declaration inventory concepts <> validateBundleLogs logs-  mapM_ (Text.IO.hPutStrLn stderr . renderBundleValidationError) coreErrors--  let logStalenessReport = logStaleness concepts logs-  mapM_ (Text.IO.hPutStrLn stderr . ("log: " <>) . renderLogStaleness) logStalenessReport--  profileViolations <- case profilePath of-    Nothing -> pure []-    Just path -> do-      spec <- loadProfileOrExit path-      compiled <- compileProfileOrExit (Text.pack path) spec-      -- With the inventory rather than without it, so a @path@ rule naming-      -- §6.3's @references/attesters/revenue.py@ is resolved rather than-      -- accepted unchecked. This command walked a real directory, so it can-      -- answer the question.-      -- The version requirement first: a bundle that does not declare what the-      -- profile demands is context for every line below it.-      let violations =-            validateProfileVersion declaration compiled-              <> validateProfileWith inventory coreProfile compiled concepts-      mapM_ (Text.IO.hPutStrLn stderr . ("profile: " <>) . renderProfileViolation compiled concepts) violations-      pure violations--  let coreFailed = any bundleValidationErrorIsFailure coreErrors-      profileFailed = profileEnforce && not (null profileViolations)-      logFailed = logEnforce && (any bundleValidationErrorIsAdvisory coreErrors || not (null logStalenessReport))-  if coreFailed || profileFailed || logFailed-    then exitFailure-    else do-      Text.IO.putStrLn-        ( "OK: "-            <> Text.pack (show (length concepts))-            <> " concepts"-            <> renderDeclaredVersion declaration-        )-      unless (null profileViolations) $-        Text.IO.putStrLn-          ( "profile: "-              <> Text.pack (show (length profileViolations))-              <> " advisory deviation(s) (use --profile-enforce to fail)"-          )-      unless (null logStalenessReport) $-        Text.IO.putStrLn-          ( "log: "-              <> Text.pack (show (length logStalenessReport))-              <> " stale concept advisory/advisories (use --log-enforce to fail)"-          )--runIndex :: IndexOptions -> IO ()-runIndex IndexOptions {bundlePath, write, okfVersion} = do-  override <- traverse requestedVersion okfVersion-  if write-    then do-      result <- writeBundleIndexesWith override bundlePath-      case result of-        Left bundleError -> dieText (renderBundleError bundleError)-        Right () -> Text.IO.putStrLn "Wrote index.md files"-    else do-      indexes <- loadIndexesOrExit override bundlePath-      mapM_ renderIndexPreview indexes-  where-    requestedVersion rawVersion =-      case parseOkfVersion rawVersion of-        Just version -> pure version-        Nothing -> dieText ("Not an OKF version of the form MAJOR.MINOR: " <> rawVersion)--runLog :: LogOptions -> IO ()-runLog LogOptions {bundlePath, checkStale, sinceRef, logSub = LogPreview} = do-  logs <- loadLogsOrExit bundlePath-  mapM_ renderLogPreview logs-  let logErrors = validateBundleLogs logs-  mapM_ (Text.IO.hPutStrLn stderr . renderBundleValidationError) logErrors-  case sinceRef of-    Nothing -> pure ()-    Just ref -> runGitDriftCheck bundlePath ref logs-  logStalenessReport <--    if checkStale-      then do-        concepts <- loadBundleOrExit bundlePath-        pure (logStaleness concepts logs)-      else pure []-  mapM_ (Text.IO.hPutStrLn stderr . ("log: " <>) . renderLogStaleness) logStalenessReport-  when (any bundleValidationErrorIsFailure logErrors) exitFailure-runLog LogOptions {bundlePath, logSub = LogAdd addOptions} =-  runLogAdd bundlePath addOptions--runLogAdd :: FilePath -> LogAddOptions -> IO ()-runLogAdd bundlePath LogAddOptions {conceptId, kind, message, date} = do-  entryDate <- maybe todayDate pure date-  targetPath <- resolveLogTarget bundlePath conceptId-  let absolutePath = bundlePath </> targetPath-      entry = Log.LogEntry {Log.logKind = Just kind, Log.logText = message}-  exists <- doesFileExist absolutePath-  existingLog <--    if exists-      then Log.parseLog <$> Text.IO.readFile absolutePath-      else pure (emptyLogFor targetPath)-  createDirectoryIfMissing True (FilePath.takeDirectory absolutePath)-  Text.IO.writeFile absolutePath (Log.serializeLog (Log.appendLogEntry entryDate entry existingLog))-  Text.IO.putStrLn ("Wrote " <> Text.pack targetPath <> " for " <> entryDate)--resolveLogTarget :: FilePath -> Maybe Text -> IO FilePath-resolveLogTarget _ Nothing =-  pure "log.md"-resolveLogTarget bundlePath (Just rawConceptId) = do-  parsed <- either (dieText . renderConceptIdError rawConceptId) pure (parseConceptId rawConceptId)-  concepts <- loadBundleOrExit bundlePath-  when (isNothing (findConcept parsed concepts)) $-    Text.IO.hPutStrLn stderr ("log: warning: concept not found: " <> rawConceptId)-  pure (logPathForConcept parsed)--logPathForConcept :: ConceptId -> FilePath-logPathForConcept conceptId =-  case FilePath.takeDirectory (conceptIdToFilePath conceptId) of-    "." -> "log.md"-    directory -> directory </> "log.md"--emptyLogFor :: FilePath -> Log.Log-emptyLogFor targetPath =-  Log.Log-    { Log.logTitle = defaultLogTitle targetPath,-      Log.logDays = []-    }--defaultLogTitle :: FilePath -> Text-defaultLogTitle targetPath =-  case FilePath.takeDirectory targetPath of-    "." -> "Bundle Update Log"-    directory -> Text.pack directory <> " Update Log"--todayDate :: IO Text-todayDate =-  Text.pack . formatTime defaultTimeLocale "%Y-%m-%d" . utctDay <$> getCurrentTime--runGitDriftCheck :: FilePath -> Text -> [LogFile] -> IO ()-runGitDriftCheck bundlePath ref logs = do-  result <--    try-      ( readProcessWithExitCode-          "git"-          ["-C", bundlePath, "diff", "--name-only", "--relative", Text.unpack ref, "--", "."]-          ""-      )-  case result of-    Left (exception :: IOException) ->-      Text.IO.hPutStrLn stderr ("log: skipped git drift check: " <> Text.pack (show exception))-    Right (exitCode, output, errOutput) ->-      case exitCode of-        ExitSuccess ->-          mapM_ (Text.IO.hPutStrLn stderr . ("git: " <>) . renderGitDrift) (gitDriftForChangedPaths logs (Text.lines (Text.pack output)))-        ExitFailure _ ->-          Text.IO.hPutStrLn stderr ("log: skipped git drift check: " <> firstNonEmpty (Text.pack errOutput) (Text.pack output))--data GitDrift = GitDrift-  { driftConceptPath :: !FilePath,-    driftLogPath :: !(Maybe FilePath)-  }-  deriving stock (Generic, Eq, Show)--gitDriftForChangedPaths :: [LogFile] -> [Text] -> [GitDrift]-gitDriftForChangedPaths logs changed =-  [ GitDrift conceptPath nearestLog-  | conceptPath <- changedConcepts,-    let nearestLog = nearestEnclosingLogPath conceptPath allLogPaths,-    maybe True (`Set.notMember` changedSet) nearestLog-  ]-  where-    changedPaths = Text.unpack <$> filter (not . Text.null) changed-    changedSet = Set.fromList changedPaths-    changedConcepts =-      [ path-      | path <- changedPaths,-        FilePath.takeExtension path == ".md",-        not (isReservedMarkdownFile path)-      ]-    changedLogs =-      [ path-      | path <- changedPaths,-        FilePath.takeFileName path == "log.md"-      ]-    allLogPaths = List.nub (changedLogs <> (logSourcePath <$> logs))--renderGitDrift :: GitDrift -> Text-renderGitDrift GitDrift {driftConceptPath, driftLogPath} =-  Text.pack driftConceptPath-    <> " changed without "-    <> maybe "an enclosing log.md" (Text.pack . (<> " changing")) driftLogPath--firstNonEmpty :: Text -> Text -> Text-firstNonEmpty primary fallback-  | Text.null (Text.strip primary) = Text.strip fallback-  | otherwise = Text.strip primary--runGraph :: GraphOptions -> IO ()-runGraph GraphOptions {bundlePath} = do-  concepts <- loadBundleOrExit bundlePath-  LazyByteString.putStrLn (Aeson.encode (buildGraph concepts))--runShow :: ShowOptions -> IO ()-runShow ShowOptions {bundlePath, conceptIdText, profilePath, computationOnly, conceptOrder} = do-  fzfConfig <- detectFzfConfig-  resolvedBundle <- resolveBundlePath fzfConfig bundlePath-  concepts <- loadBundleOrExit resolvedBundle-  -- Which renderer runs is decided once, here, so that @--computation@ behaves-  -- identically whether the concept was named on the command line or picked-  -- interactively.-  let render = if computationOnly then renderComputation resolvedBundle else renderConcept-  case conceptIdText of-    Just rawIdentifier -> showConceptByIdentifier render profilePath concepts rawIdentifier-    Nothing -> do-      selection <- selectConcept fzfConfig conceptOrder resolvedBundle concepts-      case selection of-        ConceptChosen concept -> render concept-        ConceptNoCandidates ->-          dieText ("No concepts found in " <> Text.pack resolvedBundle)-        ConceptSelectionCancelled -> exitWith (ExitFailure 130)-        ConceptSelectionUnavailable -> dieNoPicker "CONCEPT_ID"-        ConceptSelectionError message -> dieFzf message---- | Report the OKF v0.2 trust tier (§5.3), lifecycle status (§5.4), and--- staleness (§5.5) for every concept in a bundle, one aligned line each.------ Reads the clock exactly once and passes the day to 'staleness' for every--- concept, so a run that straddles midnight cannot report two concepts against--- different notions of "today". @okf-core@ never reads the clock itself; see--- @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@.------ Output is sorted by concept ID, which 'walkBundle' already guarantees, so the--- report is stable and diffable in pipelines and CI.-runTrust :: TrustOptions -> IO ()-runTrust TrustOptions {bundlePath} = do-  concepts <- loadBundleOrExit bundlePath-  today <- utctDay <$> getCurrentTime-  let rows = trustRow today <$> concepts-      widthOf column = maximum (0 : map (Text.length . column) rows)-      (idWidth, tierWidth, statusWidth) =-        (widthOf (\(a, _, _, _) -> a), widthOf (\(_, b, _, _) -> b), widthOf (\(_, _, c, _) -> c))-  mapM_-    ( \(conceptId, tier, status, stale) ->-        Text.IO.putStrLn-          ( Text.intercalate-              "  "-              [ pad idWidth conceptId,-                pad tierWidth tier,-                pad statusWidth status,-                stale-              ]-          )-    )-    rows-  where-    pad width cell = cell <> Text.replicate (width - Text.length cell) " "-    trustRow today concept =-      ( renderConceptId (conceptIdOf concept),-        renderTrustTier (trustTier (conceptVerified concept)),-        renderStatus (conceptStatus concept),-        renderStaleness (staleness today (conceptStaleAfter concept))-      )---- | List the OKF v0.2 @sources@ provenance recorded by each concept--- (specification §5.1), with the credibility signals that frame it.------ Concepts with no sources are skipped so the report shows only what has--- provenance. Output is sorted by concept ID, which 'walkBundle' guarantees, so--- the listing is stable and diffable in pipelines and CI.------ Entries are printed in the order the document declares them and are never--- sorted or ranked by @usage_count@. §5.1 warns that a count is a coarse signal--- to be read "as liveness and trend, not as a score", and a ranked listing--- would imply a precision the signal does not carry.-runSources :: SourcesOptions -> IO ()-runSources SourcesOptions {bundlePath} = do-  concepts <- loadBundleOrExit bundlePath-  let withSources = [concept | concept <- concepts, not (null (conceptSources concept))]-      labelWidth = maximum (0 : [Text.length (sourceLabel source) | concept <- withSources, source <- conceptSources concept])-  mapM_ (renderConceptSources labelWidth) withSources--renderConceptSources :: Int -> Concept -> IO ()-renderConceptSources labelWidth concept = do-  Text.IO.putStrLn (renderConceptId (conceptIdOf concept))-  mapM_ renderSource (conceptSources concept)-  where-    documentWindow = conceptUsageWindow concept-    renderSource source = do-      Text.IO.putStrLn ("  " <> pad labelWidth (sourceLabel source) <> "  " <> sourceResource source)-      -- Only signals actually present are named, so an entry with none prints-      -- no signals line rather than an empty one.-      case sourceSignals (effectiveUsageWindow documentWindow source) source of-        [] -> pure ()-        signals -> Text.IO.putStrLn ("  " <> pad labelWidth "" <> "  " <> Text.intercalate ", " signals)-    pad width cell = cell <> Text.replicate (width - Text.length cell) " "---- | An entry's @id@, or a placeholder when it has none. §5.1 makes @id@--- optional but recommends it when the body cites the source.-sourceLabel :: Source -> Text-sourceLabel Source {sourceId} = fromMaybe "(no id)" sourceId---- | The credibility signals §5.1 defines, as display phrases, omitting each one--- the entry does not carry. A @usage_count@ is always shown with the window--- that frames it, since §5.1 makes a count without a window meaningless.-sourceSignals :: Maybe UsageWindow -> Source -> [Text]-sourceSignals window Source {sourceAuthor, sourceUsageCount, sourceLastModified} =-  concat-    [ ["author " <> renderActor author | Just author <- [sourceAuthor]],-      ["used " <> Text.pack (show count) <> " times" <> windowPhrase | Just count <- [sourceUsageCount]],-      ["modified " <> modified | Just modified <- [sourceLastModified]]-    ]-  where-    windowPhrase =-      case window of-        Just (UsageWindow (Just windowFrom) (Just windowTo)) -> " in " <> windowFrom <> ".." <> windowTo-        Just (UsageWindow (Just windowFrom) Nothing) -> " since " <> windowFrom-        Just (UsageWindow Nothing (Just windowTo)) -> " until " <> windowTo-        _ -> ""---- | List the OKF v0.2 attested computations a bundle declares (specification--- §10), one aligned line each.------ This is specification §10.5 step 1 — \"Discover via @type: Attested--- Computation@\" — asked of a whole bundle at once. Every other okf command--- either takes one concept, reports every concept, or reports a different--- family, so before this there was no way to ask a bundle what computations it--- holds short of grepping @okf graph --json@.------ Every column restates frontmatter and none says anything about a run. §10.5--- marks the execute-and-attest workflow informative and puts the receipt and the--- verdict outside the bundle entirely, so a column reading \"attests cleanly\"--- would be a claim okf cannot make; see--- @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@ for the general--- principle that okf derives on read and stores nothing it was not told.------ Selection is on the exact @type@ string and nothing else. A @Metric@ that--- happens to carry a @runtime@ key is not an attested computation, and §4.1--- keeps no taxonomy okf could consult to decide otherwise.------ Absences print as a parenthesised phrase rather than as an empty cell, because--- a blank column hides exactly what @okf validate --strict@ reports: @(no--- runtime)@ is the §10.2-REQUIRED field missing, and @(2 computations)@ is--- §10.3's exactly-one rule broken.------ Output is sorted by concept ID, which 'walkBundle' already guarantees, so the--- report is stable and diffable in pipelines and CI. A bundle with no attested--- computations prints nothing and exits zero, as @okf sources@ does for a bundle--- with no provenance: an empty report is not an error.-runComputations :: ComputationsOptions -> IO ()-runComputations ComputationsOptions {bundlePath} = do-  concepts <- loadBundleOrExit bundlePath-  mapM_ Text.IO.putStrLn (computationReport concepts)---- | The lines @okf computations@ prints, as data. Pure and separate from--- 'runComputations' so a test can assert the whole report rather than only the--- accessors behind it; 'renderProfileDetail' is exported for the same reason.------ Concepts arrive in 'walkBundle' order and keep it. Column widths are computed--- over the selected rows only, so one unrelated long concept ID elsewhere in the--- bundle cannot pad this report.-computationReport :: [Concept] -> [Text]-computationReport concepts =-  [ Text.intercalate-      "  "-      [ pad idWidth conceptId,-        pad runtimeWidth runtime,-        pad parameterWidth parameters,-        pad computationWidth computation,-        contract-      ]-  | (conceptId, runtime, parameters, computation, contract) <- rows-  ]-  where-    rows = computationRow <$> filter isAttestedComputation concepts-    widthOf column = maximum (0 : map (Text.length . column) rows)-    (idWidth, runtimeWidth, parameterWidth, computationWidth) =-      ( widthOf (\(a, _, _, _, _) -> a),-        widthOf (\(_, b, _, _, _) -> b),-        widthOf (\(_, _, c, _, _) -> c),-        widthOf (\(_, _, _, d, _) -> d)-      )-    pad width cell = cell <> Text.replicate (width - Text.length cell) " "-    isAttestedComputation concept = conceptType concept == attestedComputationType-    computationRow concept =-      ( renderConceptId (conceptIdOf concept),-        fromMaybe "(no runtime)" (conceptRuntime concept),-        case conceptParameters concept of-          [] -> "(no parameters)"-          parameters -> Text.intercalate ", " (renderParameter <$> parameters),-        computationLocation (conceptComputationSources concept),-        contractHalves concept-      )-    -- Where §10.3 says the computation lives. Naming the count rather than-    -- picking one of two is the honest answer: a concept offering both is what-    -- `okf validate --strict` reports, and `okf show --computation` refuses it-    -- for the same reason.-    computationLocation = \case-      [] -> "(no computation)"-      [ComputationFile rawPath] -> rawPath-      [ComputationInline _] -> "inline"-      sources -> "(" <> countPhrase (length sources) "computation" <> ")"-    -- Which of §10.2's two run-and-check halves the concept declares. Neither is-    -- REQUIRED, so all four combinations are legitimate and the phrase says which-    -- one this is rather than passing judgement on it.-    contractHalves concept =-      case (isJust (conceptExecutor concept), isJust (conceptAttester concept)) of-        (True, True) -> "executor + attester"-        (True, False) -> "executor"-        (False, True) -> "attester"-        (False, False) -> "(neither)"---- | List the concepts a bundle holds, one aligned line each.------ The whole-bundle reports that came before this one each answered a narrower--- question — @okf trust@ always prints every concept and always the same four--- columns, @okf sources@ only concepts with provenance, @okf computations@ only--- concepts of one @type@ — so the simplest question anyone asks of a corpus,--- \"which concepts are there\", had no answer short of a @jq@ expression over--- @okf graph --json@.------ Output is sorted by concept ID, which 'walkBundle' already guarantees and--- 'filterConcepts' preserves, so the listing is stable and diffable in pipelines--- and CI. A filter that matches nothing prints nothing and exits zero, as--- @okf sources@ and @okf computations@ already do for a bundle with nothing to--- report: an empty listing is an answer, not an error.------ __A concept that omits a key never matches a filter on it__, even where OKF--- supplies a default. @--where status=stable@ selects the concepts whose--- frontmatter actually says @stable@ and not the ones that say nothing, even--- though OKF v0.2 §5.4 reads an absent @status@ as @stable@. This command--- restates frontmatter; a reading derived from absence is derived and not--- stored, per @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@.--- @okf trust@ is the command whose @status@ column does apply the default.-runConcepts :: ConceptsOptions -> IO ()-runConcepts-  ConceptsOptions-    { bundlePath,-      conceptTypes,-      fieldFilters,-      presentFields,-      absentFields,-      showFields,-      profilePath,-      json-    } = do-    traverse_ checkFiltersWithProfile profilePath-    concepts <- loadBundleOrExit bundlePath-    let selected = filterConcepts allFilters concepts-    if json-      then LazyByteString.putStrLn (Aeson.encode (conceptReportJson showFields selected))-      else mapM_ Text.IO.putStrLn (conceptReport showFields selected)-    where-      -- @--type@ is sugar for a filter on the @type@ key rather than a separate-      -- mechanism, so there is one matching path to reason about and to test,-      -- and so that @--type Policy --type Metric@ means "either" for free.-      typeFilters = [FieldEquals (TopLevelField "type") wanted | wanted <- conceptTypes]-      -- Order does not affect the result -- 'filterConcepts' groups by selector-      -- and by question -- but is kept stable so that profile diagnostics report-      -- in a predictable order.-      allFilters =-        typeFilters-          <> fieldFilters-          <> (FieldPresent <$> presentFields)-          <> (FieldAbsent <$> absentFields)--      -- Before the bundle is walked, so a typo is reported instantly on a large-      -- bundle and the diagnostic is never mixed into a partial listing. The-      -- profile is used for nothing else: it does not validate the bundle, and-      -- this command never reports a bundle deviation. That is-      -- `okf validate --profile`'s job, and duplicating it here would give two-      -- commands that disagree about severity.-      checkFiltersWithProfile path = do-        spec <- loadProfileOrExit path-        compiled <- compileProfileOrExit (Text.pack path) spec-        case checkFiltersAgainstProfile compiled conceptTypes allFilters of-          [] -> pure ()-          profileErrors -> do-            -- Every error, not only the first, so one run fixes one command line.-            mapM_ (Text.IO.hPutStrLn stderr . renderFilterProfileError) profileErrors-            exitWith (ExitFailure 1)---- | Why a profile says a filter can never select anything.------ The message quotes the __filter__, @status=acepted@, and not the flag the user--- typed. @--type Reqest@ desugars into a filter on the @type@ key before any--- checking happens, so by the time an error exists there is no flag left to--- quote and guessing one would sometimes name a flag the user did not use.-renderFilterProfileError :: FilterProfileError -> Text-renderFilterProfileError = \case-  FilterFieldNotDeclared selector ->-    "okf concepts: profile declares no frontmatter key named " <> renderFieldSelector selector-  FilterValueNotInVocabulary selector wanted vocabulary ->-    "okf concepts: no concept can match "-      <> renderFilter (FieldEquals selector wanted)-      <> "\n"-      <> renderFieldSelector selector-      <> " accepts: "-      <> Text.intercalate ", " vocabulary---- | The lines @okf concepts@ prints, as data: concept ID, @type@, one column per--- requested key, and @title@ last. Pure and separate from 'runConcepts' so a--- test can assert the whole report rather than only the accessors behind it, as--- 'computationReport' and 'renderProfileDetail' already are.------ The three default columns are the ones--- 'Okf.Cli.Fzf.Selector.conceptCandidates' shows in the interactive concept--- picker, so the two listings agree.------ @title@ comes last and is never padded, so a long title cannot push anything--- off the right edge and a concept with no title simply ends the line. Column--- widths are computed over the rows actually printed, so one unrelated long--- concept ID elsewhere in the bundle cannot pad a filtered listing.-conceptReport :: [Text] -> [Concept] -> [Text]-conceptReport shown concepts =-  map renderRow rows-  where-    rows = conceptRow <$> concepts-    columnCount = 2 + length shown + 1-    widths =-      [ maximum (0 : map (Text.length . (!! column)) rows)-      | column <- [0 .. columnCount - 1]-      ]-    -- One padder per column, in order; the last column is left as it is.-    padders = replicate (columnCount - 1) padRight <> [\_ cell -> cell]-    padRight width cell = cell <> Text.replicate (max 0 (width - Text.length cell)) " "-    renderRow cells = Text.intercalate "  " (zipWith3 id padders widths cells)-    conceptRow concept =-      [renderConceptId (conceptIdOf concept), conceptType concept]-        <> [showCell (showSelector key) concept | key <- shown]-        <> [fromMaybe "" (conceptTitle concept)]-    -- A cell restates what the frontmatter says and nothing else. Absent, or-    -- holding something a table cell cannot show, both read @-@, matching the-    -- placeholder 'renderRegistryTable' already prints for an absent optional-    -- column. @--show generated@ naming a whole mapping is the second case:-    -- @--show generated.by@ is how to ask for what is inside it.-    showCell selector concept =-      case mapMaybe scalarText (conceptFieldValues selector concept) of-        [] -> "-"-        values -> Text.intercalate ", " values---- | The rows @okf concepts --json@ emits.------ @title@ is @null@ when the concept has none, and @fields@ is present even when--- no key was requested, so a consumer never has to test for it. The values under--- @fields@ are the raw frontmatter values rather than the display text a column--- shows: a JSON consumer wants a list back as a list.-conceptReportJson :: [Text] -> [Concept] -> Aeson.Value-conceptReportJson shown concepts =-  Aeson.toJSON (conceptObject <$> concepts)-  where-    conceptObject concept =-      Aeson.object-        [ "id" Aeson..= renderConceptId (conceptIdOf concept),-          "path" Aeson..= conceptSourcePath concept,-          "type" Aeson..= conceptType concept,-          "title" Aeson..= conceptTitle concept,-          "fields"-            Aeson..= Aeson.object-              [ AesonKey.fromText key Aeson..= rawField (showSelector key) concept-              | key <- shown-              ]-        ]-    -- A top-level key reports exactly the value the document carries. A nested-    -- one has no single stored value — @reviews.outcome@ names one member of-    -- every element — so it reports the values it selected: nothing as @null@,-    -- one as itself, several as a list.-    rawField selector concept =-      case selector of-        TopLevelField key ->-          fromMaybe Aeson.Null (frontmatterLookup key (conceptDocument concept ^. #frontmatter))-        NestedField _ _ ->-          case conceptFieldValues selector concept of-            [] -> Aeson.Null-            [single] -> single-            values -> Aeson.toJSON values---- | Read a @--show@ key as a field selector. A key too deep to be one cannot--- name a real frontmatter path either, so it is kept as a top-level key and--- reports its column as absent rather than failing the run: @--show@ asks for--- display, and nothing about the listing depends on it.-showSelector :: Text -> FieldSelector-showSelector key = either (const (TopLevelField key)) Prelude.id (parseFieldSelector key)---- | Use the given bundle, or ask the user to pick one.-resolveBundlePath :: FzfConfig -> Maybe FilePath -> IO FilePath-resolveBundlePath _ (Just path) = pure path-resolveBundlePath fzfConfig Nothing = do-  selection <- selectBundle fzfConfig-  case selection of-    BundleChosen path -> pure path-    BundleNoCandidates roots ->-      dieText-        ( "No OKF bundles found under "-            <> Text.intercalate ", " (Text.pack <$> roots)-            <> ".\nA bundle directory holds an index.md or a Markdown file whose"-            <> " frontmatter declares a type."-            <> "\nPass a bundle path explicitly, or set "-            <> Text.pack bundleSearchRootsEnvVar-            <> " to a colon-separated list of directories to search."-        )-    BundleSelectionCancelled -> exitWith (ExitFailure 130)-    BundleSelectionUnavailable -> dieNoPicker "BUNDLE"-    BundleSelectionError message -> dieFzf message---- | The argument was omitted but no interactive picker can run.-dieNoPicker :: Text -> IO a-dieNoPicker missingArgument =-  dieTextWith-    (ExitFailure 2)-    ( "okf show: no "-        <> missingArgument-        <> " given and interactive selection is unavailable."-        <> "\nInstall fzf (https://github.com/junegunn/fzf) and run okf from a terminal,"-        <> " or pass the argument: okf show [BUNDLE] [CONCEPT_ID]"-    )--dieFzf :: Text -> IO a-dieFzf message =-  dieTextWith (ExitFailure 2) ("okf show: interactive selection failed: " <> message)---- | Resolve one identifier against a walked bundle: canonical concept path--- first, then a profile-declared document ID. Unchanged from the previous--- implementation of 'runShow', so the resolution order fixed by ADR 1 cannot--- drift.------ The renderer is a parameter rather than 'renderConcept' directly, so that--- @okf show --computation@ resolves an identifier by exactly the same rules as--- @okf show@.-showConceptByIdentifier :: (Concept -> IO ()) -> Maybe FilePath -> [Concept] -> Text -> IO ()-showConceptByIdentifier renderChosen profilePath concepts conceptIdText =-  case either (const Nothing) (`findConcept` concepts) (parseConceptId conceptIdText) of-    Just concept -> renderChosen concept-    Nothing ->-      case parseDocumentId conceptIdText of-        Nothing ->-          case parseConceptId conceptIdText of-            Left err -> dieText (renderConceptIdError conceptIdText err)-            Right _ -> dieText ("Concept not found: " <> conceptIdText)-        Just _ -> do-          searchField <--            case profilePath of-              Nothing -> pure Nothing-              Just path -> do-                ProfileSpec {idField = profileIdField} <- loadProfileOrExit path-                maybe-                  (dieText ("Profile " <> Text.pack path <> " declares no idField"))-                  (pure . Just)-                  profileIdField-          case findConceptsByDocumentId searchField conceptIdText concepts of-            [] ->-              dieText-                ( "Concept not found: "-                    <> conceptIdText-                    <> " (no document carries that document ID)"-                )-            [concept] -> renderChosen concept-            matches ->-              dieText-                ( "Ambiguous document ID "-                    <> conceptIdText-                    <> ", found on: "-                    <> Text.intercalate ", " (renderConceptId . conceptIdOf <$> matches)-                    <> "\nRun okf validate --profile <descriptor> to see the duplicate as a violation."-                )--runId :: IdOptions -> IO ()-runId IdOptions {bundlePath, profilePath, idSub} = do-  spec <- loadProfileOrExit profilePath-  ProfileSpec {idField = profileIdField, types = typeRules} <- pure spec-  when (isNothing profileIdField) $-    dieText ("Profile " <> Text.pack profilePath <> " declares no idField")-  concepts <- loadBundleOrExit bundlePath+  ( BundlesOptions (..),+    Command (..),+    ComputationsOptions (..),+    ConceptsOptions (..),+    GraphOptions (..),+    IdOptions (..),+    IdSub (..),+    IndexOptions (..),+    ConfigCommand (..),+    LogAddOptions (..),+    LogOptions (..),+    LogSub (..),+    Options (..),+    ProfileCommand (..),+    ProfileDocumentOptions (..),+    ProfileListOptions (..),+    ProfileSourcesOptions (..),+    ProfileSourceResolution (..),+    ProfileSourceStatus (..),+    ProfileFreshness (..),+    ProfilesOptions (..),+    ProfileSourceOrigin (..),+    ProfileShowOptions (..),+    ResolvedProfileSource (..),+    RegistryTableMode (..),+    ReleaseVersion (..),+    ShowOptions (..),+    SourcesOptions (..),+    TrustOptions (..),+    ValidateOptions (..),+    bundleListJson,+    computationReport,+    conceptReport,+    conceptReportJson,+    observedIdPrefixes,+    parserInfo,+    parseProfileRegistriesEnv,+    parseReleaseVersionTag,+    latestReleaseTag,+    pinnedRegistryTag,+    profileRegistriesEnvVar,+    profileRegistryEnvVar,+    registryListJson,+    profileSourcesJson,+    profileDescriptorJson,+    resolveEffectiveProfileSources,+    resolveProfilePathWith,+    resolveProfileSources,+    renderProfileDetail,+    renderProfileViolation,+    renderRegistryTable,+    renderProfileSourceResolution,+    runCli,+    runCommand,+    runLogAdd,+    selectSourcedProfile,+  )+where++import Control.Exception (IOException, try)+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as AesonKey+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Lazy qualified as LazyBytes+import Data.ByteString.Lazy.Char8 qualified as LazyByteString+import Data.Char (isDigit)+import Data.Foldable (toList, traverse_)+import Data.List qualified as List+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes, listToMaybe, mapMaybe)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import Data.Text.IO qualified as Text.IO+import Data.Time (defaultTimeLocale, formatTime, getCurrentTime, utctDay)+import Okf.Actor (parseActor, renderActor)+import Okf.Bundle+import Okf.Cli.Agent.Config+  ( AgentCommandName (..),+    AgentField (..),+    AgentOverrides (..),+    ResolvedAgent,+    agentFieldEnvVar,+    allAgentCommands,+    noAgentOverrides,+    parseOkfEffort,+    parseOkfProvider,+    renderAgentResolution,+    resolveAgent,+  )+import Okf.Cli.Assist (AssistOptions, assistAgentOverrides, assistOptionsParser, handleAssistCommand)+import Okf.Cli.BundleDiscovery (BundleDiscovery (..), discoverAvailableBundles)+import Okf.Cli.Completions (CompletionsShell, completionsParser, handleCompletions)+import Okf.Cli.Config+import Okf.Cli.Fzf (FzfConfig, detectFzfConfig)+import Okf.Cli.Fzf.Selector+  ( BundleSelection (..),+    ConceptOrder (..),+    ConceptSelection (..),+    ProfileSelection (..),+    bundleSearchRootsEnvVar,+    parseConceptOrder,+    renderConceptOrder,+    selectBundle,+    selectConcept,+    selectProfileDescriptor,+  )+import Okf.Cli.Help (HelpCommand, handleHelpCommand, helpCommandParser)+import Okf.Cli.Kit (KitCommand, handleKitCommand, kitCommandParser)+import Okf.Cli.ProfileDiscovery+  ( ProfileDiscovery (..),+    discoverAvailableProfiles,+    profileSearchRootsEnvVar,+  )+import Okf.Cli.Version (appVersionWithGit)+import Okf.ConceptId+import Okf.Document+  ( Attester (..),+    ComputationSource (..),+    DocumentParseError (..),+    Executor (..),+    Frontmatter (..),+    Generated (..),+    OKFDocument (..),+    Parameter (..),+    Source (..),+    UsageWindow (..),+    attestedComputationType,+    body,+    effectiveUsageWindow,+    renderStatus,+  )+import Okf.Graph (buildGraph)+import Okf.Index+import Okf.Log qualified as Log+import Okf.Path (PathReference (..), classifyPathReference)+-- 'List' and 'Object' are 'Okf.Profile.Cardinality' constructors here; aeson's+-- same-named 'Value' constructors are reached as 'Aeson.Object' and friends.+import Okf.Prelude hiding (List, Object)+import Okf.Profile+  ( Cardinality (..),+    CompiledProfile,+    DocumentId (..),+    FieldCondition (..),+    FieldFormat (..),+    FieldPath (..),+    FieldPathSegment (..),+    -- 'FrontmatterRules' and 'NestedRules' are deliberately absent: importing+    -- their field selectors would make @optional@ ambiguous against+    -- @optparse-applicative@'s, so this module reads all three presence lists+    -- through generic-lens labels instead.+    HandleReferenceRule (..),+    PathReferenceRule (..),+    ProfileDefinitionError (..),+    ProfileSpec (..),+    ProfileViolation (..),+    TypeRule (..),+    compileProfile,+    compiledProfileRulesForType,+    documentIdsInBundle,+    fieldRuleDescription,+    fieldRuleElementFields,+    fieldRuleObjectFields,+    loadProfileFile,+    nextDocumentId,+    parseDocumentId,+    profileFieldDescriptionForType,+    renderDocumentId,+    validateProfileVersion,+    validateProfileWith,+  )+import Okf.Profile.Discovery (loadProfileDescriptorWithoutNetwork)+import Okf.Profile.Documentation+  ( DocumentationError (..),+    DocumentationOptions (..),+    defaultDocumentationActor,+    defaultDocumentationOptions,+    renderProfileDocumentation,+  )+import Okf.Profile.Registry+  ( ProfileSource (..),+    ProfileSourceLoadError,+    RegistryEntry (..),+    RegistryRef (..),+    SourceFailure (..),+    SourcedProfile (..),+    defaultRegistryReference,+    findSourcedProfiles,+    loadProfileSourceDetailed,+    loadProfileSources,+    profileSourceLoadErrorCategory,+    renderProfileSourceLabel,+    renderProfileSourceLoadError,+    renderProfileSourceReference,+    renderRegistryRef,+    resolveRegistryRef,+    rootExportLabel,+  )+import Okf.Query+  ( ConceptFilter (..),+    FieldSelector (..),+    FilterProfileError (..),+    checkFiltersAgainstProfile,+    conceptFieldValues,+    filterConcepts,+    parseFieldEquals,+    parseFieldSelector,+    renderFieldSelector,+    renderFilter,+    renderFilterParseError,+    scalarText,+  )+import Okf.Trust+  ( Staleness (..),+    latestVerification,+    renderStaleness,+    renderTrustTier,+    staleness,+    trustTier,+  )+import Okf.Validation+import Options.Applicative+import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.Environment (lookupEnv)+import System.Exit (ExitCode (..), exitFailure, exitWith)+import System.FilePath ((</>))+import System.FilePath qualified as FilePath+import System.IO (stderr)+import System.Process (readProcessWithExitCode)+import Text.Read (readMaybe)++data Command+  = Bundles BundlesOptions+  | Profiles ProfilesOptions+  | Validate ValidateOptions+  | Index IndexOptions+  | Log LogOptions+  | GraphCommand GraphOptions+  | ShowConcept ShowOptions+  | Trust TrustOptions+  | Sources SourcesOptions+  | Computations ComputationsOptions+  | Concepts ConceptsOptions+  | Id IdOptions+  | Config ConfigCommand+  | Profile ProfileCommand+  | Kit KitCommand+  | Assist AssistOptions+  | Completions CompletionsShell+  | Help HelpCommand+  deriving stock (Show, Eq)++data BundlesOptions = BundlesOptions+  { json :: !Bool+  }+  deriving stock (Show, Eq)++data ProfilesOptions = ProfilesOptions+  { json :: !Bool+  }+  deriving stock (Show, Eq)++data ValidateOptions = ValidateOptions+  { bundlePath :: !(Maybe FilePath),+    strictMode :: !Bool,+    profilePath :: !(Maybe FilePath),+    pickProfile :: !Bool,+    profileEnforce :: !Bool,+    logEnforce :: !Bool+  }+  deriving stock (Show, Eq)++data IndexOptions = IndexOptions+  { bundlePath :: !(Maybe FilePath),+    write :: !Bool,+    okfVersion :: !(Maybe Text)+  }+  deriving stock (Show, Eq)++data LogOptions = LogOptions+  { bundlePath :: !(Maybe FilePath),+    checkStale :: !Bool,+    sinceRef :: !(Maybe Text),+    logSub :: !LogSub+  }+  deriving stock (Show, Eq)++data LogSub+  = LogPreview+  | LogAdd LogAddOptions+  deriving stock (Show, Eq)++data LogAddOptions = LogAddOptions+  { conceptId :: !(Maybe Text),+    kind :: !Text,+    message :: !Text,+    date :: !(Maybe Text)+  }+  deriving stock (Show, Eq)++data GraphOptions = GraphOptions+  { bundlePath :: !(Maybe FilePath),+    json :: !Bool+  }+  deriving stock (Show, Eq)++data ShowOptions = ShowOptions+  { bundlePath :: !(Maybe FilePath),+    conceptIdText :: !(Maybe Text),+    profilePath :: !(Maybe FilePath),+    computationOnly :: !Bool,+    -- | Order of the interactive concept menu; ignored when @CONCEPT_ID@ is+    -- given, because then there is no menu.+    conceptOrder :: !ConceptOrder+  }+  deriving stock (Show, Eq)++data TrustOptions = TrustOptions+  { bundlePath :: !(Maybe FilePath)+  }+  deriving stock (Show, Eq)++data SourcesOptions = SourcesOptions+  { bundlePath :: !(Maybe FilePath)+  }+  deriving stock (Show, Eq)++data ComputationsOptions = ComputationsOptions+  { bundlePath :: !(Maybe FilePath)+  }+  deriving stock (Show, Eq)++data ConceptsOptions = ConceptsOptions+  { bundlePath :: !(Maybe FilePath),+    conceptTypes :: ![Text],+    fieldFilters :: ![ConceptFilter],+    presentFields :: ![FieldSelector],+    absentFields :: ![FieldSelector],+    showFields :: ![Text],+    profilePath :: !(Maybe FilePath),+    json :: !Bool+  }+  deriving stock (Show, Eq)++data IdOptions = IdOptions+  { bundlePath :: !(Maybe FilePath),+    profilePath :: !FilePath,+    idSub :: !IdSub+  }+  deriving stock (Show, Eq)++data IdSub+  = IdNext !Text+  | IdList+  deriving stock (Show, Eq)++data ConfigCommand+  = ConfigShow+  | ConfigPath+  | ConfigInit !Bool+  | ConfigAgent+  deriving stock (Show, Eq)++data ProfileCommand+  = ProfileList ProfileListOptions+  | ProfileSources ProfileSourcesOptions+  | ProfileShow ProfileShowOptions+  | ProfileDocument ProfileDocumentOptions+  deriving stock (Show, Eq)++data ProfileListOptions = ProfileListOptions+  { registryRefs :: ![Text],+    noLocal :: !Bool,+    json :: !Bool,+    wide :: !Bool+  }+  deriving stock (Show, Eq)++data ProfileSourcesOptions = ProfileSourcesOptions+  { registryRefs :: ![Text],+    noLocal :: !Bool,+    json :: !Bool,+    checkLatest :: !Bool+  }+  deriving stock (Show, Eq)++data ProfileShowOptions = ProfileShowOptions+  { registryRefs :: ![Text],+    export :: !(Maybe Text),+    noLocal :: !Bool,+    json :: !Bool+  }+  deriving stock (Show, Eq)++data ProfileDocumentOptions = ProfileDocumentOptions+  { registryRefs :: ![Text],+    export :: !(Maybe Text),+    profilePath :: !(Maybe FilePath),+    noLocal :: !Bool,+    outputPath :: !(Maybe FilePath),+    write :: !Bool,+    timestamp :: !(Maybe Text),+    generatedBy :: !(Maybe Text),+    generatedAt :: !(Maybe Text),+    okfVersion :: !(Maybe Text)+  }+  deriving stock (Show, Eq)++-- | The winning precedence layer for a resolved profile source. This travels+-- with the source so inspection never has to reconstruct resolution later.+data ProfileSourceOrigin+  = RegistryFlagOrigin+  | RegistriesEnvironmentOrigin+  | LegacyRegistryEnvironmentOrigin+  | ProfileConfigOrigin !FilePath+  | BuiltInRegistryOrigin+  | ProfileDiscoveryOrigin ![FilePath]+  deriving stock (Show, Eq)++data ResolvedProfileSource = ResolvedProfileSource+  { resolvedSource :: !ProfileSource,+    sourceOrigin :: !ProfileSourceOrigin+  }+  deriving stock (Show, Eq)++data ProfileSourceStatus+  = ProfileSourceLoaded !Int+  | ProfileSourceFailed !ProfileSourceLoadError+  deriving stock (Show, Eq)++data ProfileSourceResolution = ProfileSourceResolution+  { resolvedProfileSource :: !ResolvedProfileSource,+    profileSourceStatus :: !ProfileSourceStatus+  }+  deriving stock (Show, Eq)++data ProfileFreshness+  = FreshnessNotChecked+  | FreshnessCurrent !Text+  | FreshnessOutdated !Text !Text+  | FreshnessUnavailable !Text+  deriving stock (Show, Eq)++data RegistryTableMode = CompactTable | WideTable+  deriving stock (Show, Eq)++data ReleaseVersion = ReleaseVersion !Int !Int !Int+  deriving stock (Show, Eq, Ord)++data Options = Options+  { cmd :: !Command+  }+  deriving stock (Show, Eq)++runCli :: IO ()+runCli = do+  Options {cmd} <- execParser parserInfo+  runCommand cmd++parserInfo :: ParserInfo Options+parserInfo =+  info+    (optionsParser <**> helper <**> versionOption)+    ( fullDesc+        <> progDesc "Validate, index, inspect, and graph Open Knowledge Format bundles"+        <> header "okf - Open Knowledge Format bundle tools"+    )++versionOption :: Parser (a -> a)+versionOption =+  infoOption+    (Text.unpack appVersionWithGit)+    (long "version" <> help "Show version information and exit")++optionsParser :: Parser Options+optionsParser = Options <$> commandParser++commandParser :: Parser Command+commandParser =+  hsubparser+    ( command "bundles" (info (Bundles <$> bundlesOptionsParser <**> helper) (progDesc "List discovered OKF bundles"))+        <> command "profiles" (info (Profiles <$> profilesOptionsParser <**> helper) (progDesc "List local profile descriptor paths; use `okf profile list` for all effective sources"))+        <> command "validate" (info (Validate <$> validateOptionsParser <**> helper) (progDesc "Validate an OKF bundle"))+        <> command "index" (info (Index <$> indexOptionsParser <**> helper) (progDesc "Preview or write generated index.md files"))+        <> command "log" (info (Log <$> logOptionsParser <**> helper) (progDesc "Preview and check log.md files"))+        <> command "graph" (info (GraphCommand <$> graphOptionsParser <**> helper) (progDesc "Print a bundle graph"))+        <> command "show" (info (ShowConcept <$> showOptionsParser <**> helper) (progDesc "Show one concept"))+        <> command "trust" (info (Trust <$> trustOptionsParser <**> helper) (progDesc "Report trust tiers, status, and staleness for every concept"))+        <> command "sources" (info (Sources <$> sourcesOptionsParser <**> helper) (progDesc "List the provenance recorded by each concept"))+        <> command "computations" (info (Computations <$> computationsOptionsParser <**> helper) (progDesc "List the attested computations a bundle declares"))+        <> command "concepts" (info (Concepts <$> conceptsOptionsParser <**> helper) (progDesc "List the concepts a bundle holds, with optional filters"))+        <> command "id" (info (Id <$> idOptionsParser <**> helper) (progDesc "Allocate and list document IDs"))+        <> command "config" (info (Config <$> configCommandParser <**> helper) (progDesc "Show and manage okf configuration"))+        <> command "profile" (info (Profile <$> profileCommandParser <**> helper) (progDesc "List and inspect profiles from registries and local descriptors"))+        <> command "kit" (info (Kit <$> kitCommandParser <**> helper) (progDesc "Install and manage agent skills and subagents"))+        <> command "assist" (info (Assist <$> assistOptionsParser <**> helper) (progDesc "Launch an interactive agent session with installed okf skills"))+        <> command "completions" (info (Completions <$> completionsParser <**> helper) (progDesc "Generate a shell completion script (bash, zsh, fish)"))+        <> command "help" (info (Help <$> helpCommandParser <**> helper) (progDesc "Show conceptual help topics"))+    )++bundlesOptionsParser :: Parser BundlesOptions+bundlesOptionsParser = BundlesOptions <$> jsonSwitch++profilesOptionsParser :: Parser ProfilesOptions+profilesOptionsParser = ProfilesOptions <$> jsonSwitch++validateOptionsParser :: Parser ValidateOptions+validateOptionsParser =+  ValidateOptions+    <$> optionalBundleArgument+    <*> switch (long "strict" <> help "Require recommended authoring fields")+    <*> optional+      ( strOption+          ( long "profile"+              <> metavar "PROFILE"+              <> help "Path to a Dhall profile descriptor to check (advisory)"+          )+      )+    <*> switch (long "pick-profile" <> help "Choose a discovered local profile descriptor interactively")+    <*> switch (long "profile-enforce" <> help "Exit non-zero when profile checks find deviations")+    <*> switch (long "log-enforce" <> help "Exit non-zero when log staleness advisories are found")++indexOptionsParser :: Parser IndexOptions+indexOptionsParser =+  IndexOptions+    <$> optionalBundleArgument+    <*> switch (long "write" <> help "Write generated index.md files instead of previewing")+    <*> optional+      ( strOption+          ( long "okf-version"+              <> metavar "MAJOR.MINOR"+              <> help "Declare the OKF version in the bundle root index"+          )+      )++logOptionsParser :: Parser LogOptions+logOptionsParser =+  logAddCommandParser <|> logPreviewOptionsParser++logPreviewOptionsParser :: Parser LogOptions+logPreviewOptionsParser =+  LogOptions+    <$> optionalBundleArgument+    <*> switch (long "check-stale" <> help "Report concepts newer than their nearest log.md")+    <*> optional+      ( Text.pack+          <$> strOption+            ( long "since"+                <> metavar "GIT_REF"+                <> help "Report git drift since a ref (implemented in Milestone 6)"+            )+      )+    <*> pure LogPreview++logAddCommandParser :: Parser LogOptions+logAddCommandParser =+  hsubparser+    ( command+        "add"+        ( info+            (logAddOptionsToCommand <$> optionalBundleArgument <*> logAddOptionsParser <**> helper)+            (progDesc "Append an entry to the nearest log.md")+        )+    )++logAddOptionsToCommand :: Maybe FilePath -> LogAddOptions -> LogOptions+logAddOptionsToCommand path addOptions =+  LogOptions+    { bundlePath = path,+      checkStale = False,+      sinceRef = Nothing,+      logSub = LogAdd addOptions+    }++logAddOptionsParser :: Parser LogAddOptions+logAddOptionsParser =+  LogAddOptions+    <$> optional (Text.pack <$> strArgument (metavar "CONCEPT_ID" <> help "Concept ID whose directory log.md should be updated"))+    <*> ( Text.pack+            <$> strOption+              ( long "kind"+                  <> metavar "KIND"+                  <> value "Update"+                  <> showDefault+                  <> help "Leading bold log entry kind"+              )+        )+    <*> ( Text.pack+            <$> strOption+              ( short 'm'+                  <> long "message"+                  <> metavar "MESSAGE"+                  <> help "Log entry message"+              )+        )+    <*> optional+      ( Text.pack+          <$> strOption+            ( long "date"+                <> metavar "YYYY-MM-DD"+                <> help "Entry date; defaults to today in UTC"+            )+      )++graphOptionsParser :: Parser GraphOptions+graphOptionsParser =+  GraphOptions+    <$> optionalBundleArgument+    <*> switch (long "json" <> help "Print JSON graph output")++showOptionsParser :: Parser ShowOptions+showOptionsParser =+  ShowOptions+    <$> optionalBundleArgument+    <*> optional+      ( Text.pack+          <$> strArgument+            ( metavar "CONCEPT_ID"+                <> help "Concept ID such as tables/users; omit to choose one interactively"+            )+      )+    <*> optional+      ( strOption+          ( long "profile"+              <> metavar "PROFILE"+              <> help "Narrow document ID lookup to a profile's idField"+          )+      )+    <*> switch+      ( long "computation"+          <> help "Print only the computation and nothing else"+      )+    <*> option+      (maybeReader parseConceptOrder)+      ( long "sort"+          <> metavar "ORDER"+          <> value ByModifiedTime+          <> showDefaultWith (Text.unpack . renderConceptOrder)+          <> help "Order of the interactive concept menu: modified or id"+      )++idOptionsParser :: Parser IdOptions+idOptionsParser =+  hsubparser+    ( command+        "next"+        ( info+            (idNextOptionsParser <**> helper)+            (progDesc "Print the next unused document ID")+        )+        <> command+          "list"+          ( info+              (IdOptions <$> optionalBundleArgument <*> profileArgument <*> pure IdList <**> helper)+              (progDesc "List allocated document IDs")+          )+    )+  where+    idNextOptionsParser =+      toIdNext+        <$> (Text.pack <$> strArgument (metavar "BUNDLE_OR_PREFIX" <> help "Bundle path in the two-argument form, or the document ID prefix when choosing a bundle interactively"))+        <*> optional (Text.pack <$> strArgument (metavar "PREFIX" <> help "Profile-declared document ID prefix when BUNDLE is given"))+        <*> profileArgument+    toIdNext firstArgument second selectedProfile =+      case second of+        Nothing -> IdOptions {bundlePath = Nothing, profilePath = selectedProfile, idSub = IdNext firstArgument}+        Just selectedPrefix -> IdOptions {bundlePath = Just (Text.unpack firstArgument), profilePath = selectedProfile, idSub = IdNext selectedPrefix}+    profileArgument =+      strOption+        ( long "profile"+            <> metavar "PROFILE"+            <> help "Path to a Dhall profile descriptor declaring idField and idPrefix"+        )++configCommandParser :: Parser ConfigCommand+configCommandParser =+  hsubparser+    ( command "show" (info (pure ConfigShow) (progDesc "Print the effective configuration and its source"))+        <> command "path" (info (pure ConfigPath) (progDesc "Print the path the configuration was loaded from"))+        <> command+          "init"+          ( info+              (ConfigInit <$> switch (long "global" <> help "Write to ~/.config/okf/config.dhall instead of ./okf-config.dhall"))+              (progDesc "Write a commented example okf-config.dhall")+          )+        <> command+          "agent"+          ( info+              (pure ConfigAgent)+              (progDesc "Show the resolved agent settings and where each came from")+          )+    )+    <|> pure ConfigShow++profileCommandParser :: Parser ProfileCommand+profileCommandParser =+  hsubparser+    ( command+        "list"+        ( info+            (ProfileList <$> profileListOptionsParser <**> helper)+            (progDesc "List profiles from registries and local descriptors; use `okf profiles` for local paths only")+        )+        <> command+          "sources"+          ( info+              (ProfileSources <$> profileSourcesOptionsParser <**> helper)+              (progDesc "Show effective profile sources, their origins, and whether they load")+          )+        <> command+          "show"+          ( info+              (ProfileShow <$> profileShowOptionsParser <**> helper)+              (progDesc "Print one registry profile in full")+          )+        <> command+          "document"+          ( info+              (ProfileDocument <$> profileDocumentOptionsParser <**> helper)+              (progDesc "Generate an OKF bundle documenting a profile")+          )+    )+    <|> pure (ProfileList (ProfileListOptions [] False False False))++profileListOptionsParser :: Parser ProfileListOptions+profileListOptionsParser =+  ProfileListOptions+    <$> many registryOption+    <*> noLocalSwitch+    <*> jsonSwitch+    <*> switch (long "wide" <> help "Print full source, export, name, and description values")++profileSourcesOptionsParser :: Parser ProfileSourcesOptions+profileSourcesOptionsParser =+  ProfileSourcesOptions+    <$> many registryOption+    <*> noLocalSwitch+    <*> jsonSwitch+    <*> switch (long "check-latest" <> help "Query upstream tags and compare them with the pinned default release")++profileShowOptionsParser :: Parser ProfileShowOptions+profileShowOptionsParser =+  ProfileShowOptions+    <$> many registryOption+    <*> optional+      ( Text.pack+          <$> strArgument+            ( metavar "EXPORT"+                <> help "Dotted export path of the profile, as printed by `okf profile list`"+            )+      )+    <*> noLocalSwitch+    <*> jsonSwitch++profileDocumentOptionsParser :: Parser ProfileDocumentOptions+profileDocumentOptionsParser =+  ProfileDocumentOptions+    <$> many registryOption+    <*> optional+      ( Text.pack+          <$> strArgument+            ( metavar "EXPORT"+                <> help "Dotted export path of the profile, as printed by `okf profile list`"+            )+      )+    <*> optional+      ( strOption+          ( long "profile"+              <> metavar "PROFILE"+              <> help "Document a Dhall descriptor file directly instead of a registry export"+          )+      )+    <*> noLocalSwitch+    <*> optional+      ( strOption+          ( long "out"+              <> metavar "DIR"+              <> help "Directory to write the generated bundle into"+          )+      )+    <*> switch+      ( long "write"+          <> help "Write the bundle to --out instead of previewing it on standard output"+      )+    <*> optional+      ( Text.pack+          <$> strOption+            ( long "timestamp"+                <> metavar "RFC3339"+                <> help "Value for the superseded v0.1 timestamp frontmatter key; omitted entirely when not given. Provenance is written as generated.by by default, so this is needed only when producing a v0.1 bundle."+            )+      )+    <*> optional+      ( Text.pack+          <$> strOption+            ( long "generated-by"+                <> metavar "ACTOR"+                <> help "Actor recorded in generated.by on every page: <producer>/<version>, human:<id>, or process:<id>. Defaults to process:okf-profile-document."+            )+      )+    <*> optional+      ( Text.pack+          <$> strOption+            ( long "generated-at"+                <> metavar "RFC3339"+                <> help "Timestamp recorded in generated.at. Omitted when not given, because generation never reads the clock."+            )+      )+    <*> optional+      ( strOption+          ( long "okf-version"+              <> metavar "MAJOR.MINOR"+              <> help "Declare the OKF version in the generated bundle's root index"+          )+      )++registryOption :: Parser Text+registryOption =+  Text.pack+    <$> strOption+      ( long "registry"+          <> metavar "REGISTRY"+          <> help "Dhall file, directory holding package.dhall, or Dhall expression publishing profiles; repeat to merge sources"+      )++noLocalSwitch :: Parser Bool+noLocalSwitch = switch (long "no-local" <> help "Do not append discovered local profile descriptors")++jsonSwitch :: Parser Bool+jsonSwitch = switch (long "json" <> help "Emit JSON instead of text")++optionalBundleArgument :: Parser (Maybe FilePath)+optionalBundleArgument =+  optional+    ( strArgument+        ( metavar "BUNDLE"+            <> help "Path to an OKF bundle directory; omit to choose one interactively"+        )+    )++trustOptionsParser :: Parser TrustOptions+trustOptionsParser = TrustOptions <$> optionalBundleArgument++sourcesOptionsParser :: Parser SourcesOptions+sourcesOptionsParser = SourcesOptions <$> optionalBundleArgument++computationsOptionsParser :: Parser ComputationsOptions+computationsOptionsParser = ComputationsOptions <$> optionalBundleArgument++-- A malformed filter is rejected here rather than in 'runConcepts', so a typo+-- fails before the bundle is walked. 'eitherReader' rather than 'maybeReader' so+-- that the message a user sees is ours: optparse-applicative prints+-- @option --where: \<message\>@ and exits 1, which is what every other flag in+-- this tool does with a bad value.+conceptsOptionsParser :: Parser ConceptsOptions+conceptsOptionsParser =+  ConceptsOptions+    <$> optionalBundleArgument+    <*> many+      ( Text.pack+          <$> strOption+            ( long "type"+                <> metavar "TYPE"+                <> help "Keep concepts whose type is exactly TYPE; repeat for any-of"+            )+      )+    <*> many+      ( option+          (eitherReader (first (Text.unpack . renderFilterParseError) . parseFieldEquals . Text.pack))+          ( long "where"+              <> metavar "KEY=VALUE"+              <> help+                "Keep concepts whose frontmatter KEY holds VALUE; KEY may be nested one level (reviews.outcome). Repeat the same key for any-of, different keys for all-of"+          )+      )+    <*> many (option fieldSelectorReader (long "has" <> metavar "KEY" <> help "Keep concepts that carry KEY at all"))+    <*> many (option fieldSelectorReader (long "missing" <> metavar "KEY" <> help "Keep concepts that do not carry KEY"))+    <*> many+      ( Text.pack+          <$> strOption+            ( long "show"+                <> metavar "KEY"+                <> help "Add a column displaying KEY; repeat for more columns"+            )+      )+    <*> optional+      ( strOption+          ( long "profile"+              <> metavar "PROFILE"+              <> help "Path to a Dhall profile descriptor; reject a filter no concept could satisfy"+          )+      )+    <*> jsonSwitch+  where+    fieldSelectorReader =+      eitherReader (first (Text.unpack . renderFilterParseError) . parseFieldSelector . Text.pack)++runCommand :: Command -> IO ()+runCommand = \case+  Bundles options -> runBundles options+  Profiles options -> runProfiles options+  Validate options -> runValidate options+  Index options -> runIndex options+  Log options -> runLog options+  GraphCommand options -> runGraph options+  ShowConcept options -> runShow options+  Trust options -> runTrust options+  Sources options -> runSources options+  Computations options -> runComputations options+  Concepts options -> runConcepts options+  Id options -> runId options+  Config configCommand -> runConfig configCommand+  Profile profileCommand -> runProfile profileCommand+  Kit kitCommand -> do+    config <- loadConfigOrDie+    handleKitCommand config kitCommand+  Assist assistOptions -> do+    config <- loadConfigOrDie+    resolved <- resolveAgentOrDie AgentCmdAssist (assistAgentOverrides assistOptions)+    handleAssistCommand config resolved assistOptions+  Completions shell -> handleCompletions shell+  Help helpCommand -> handleHelpCommand helpCommand++-- | List the same normalized candidates offered by the interactive picker.+-- Text mode does not walk bundles. JSON mode enriches each candidate with+-- strict handle prefixes observed in its concepts, degrading to a path-only+-- entry if the candidate cannot be walked.+runBundles :: BundlesOptions -> IO ()+runBundles BundlesOptions {json} = do+  BundleDiscovery {bundlePaths} <- discoverAvailableBundles+  if json+    then do+      entries <- traverse enrich bundlePaths+      LazyByteString.putStrLn (Aeson.encode (bundleListJson entries))+    else traverse_ putStrLn bundlePaths+  where+    enrich path = do+      walked <- walkBundle path+      pure (path, either (const []) observedIdPrefixes walked)++-- | List the same normalized descriptor paths offered by the profile picker.+-- Text mode prints paths only. JSON mode decodes each already-qualified file+-- again under the same network-silent loader to attach useful profile metadata;+-- a file that changed between discovery and enrichment is silently omitted.+runProfiles :: ProfilesOptions -> IO ()+runProfiles ProfilesOptions {json} = do+  ProfileDiscovery {descriptorPaths} <- discoverAvailableProfiles+  if json+    then do+      entries <-+        catMaybes+          <$> traverse+            ( \path -> do+                loaded <- loadProfileDescriptorWithoutNetwork path+                pure (profileDescriptorJson path <$> either (const Nothing) Just loaded)+            )+            descriptorPaths+      LazyByteString.putStrLn (Aeson.encode entries)+    else traverse_ putStrLn descriptorPaths++-- | JSON wire format for one discovered descriptor. Optional prose is omitted+-- rather than rendered as null, matching the bundle-listing convention.+profileDescriptorJson :: FilePath -> ProfileSpec -> Aeson.Value+profileDescriptorJson path ProfileSpec {name, description, okfVersion} =+  Aeson.object+    ( [ "path" Aeson..= path,+        "name" Aeson..= name,+        "okfVersion" Aeson..= okfVersion+      ]+        <> maybe [] (\descriptionText -> ["description" Aeson..= descriptionText]) description+    )++-- | Sorted, duplicate-free strict handle prefixes observed anywhere in the+-- top-level string frontmatter of the supplied concepts.+observedIdPrefixes :: [Concept] -> [Text]+observedIdPrefixes concepts =+  List.nub . List.sort $+    mapMaybe+      observedPrefix+      [fieldValue | concept <- concepts, (_, fieldValue) <- documentIdFields concept]+  where+    observedPrefix fieldValue = do+      DocumentId {prefix} <- parseDocumentId fieldValue+      pure prefix++-- | JSON wire format for @okf bundles --json@. An empty prefix list means no+-- evidence was observed, so the optional field is absent rather than empty.+bundleListJson :: [(FilePath, [Text])] -> Aeson.Value+bundleListJson entries =+  Aeson.toJSON+    [ Aeson.object+        ( ["path" Aeson..= path]+            <> ["idPrefixes" Aeson..= prefixes | not (null prefixes)]+        )+    | (path, prefixes) <- entries+    ]++runConfig :: ConfigCommand -> IO ()+runConfig = \case+  ConfigShow -> do+    (config, configSource) <- loadConfigWithSourceOrDie+    Text.IO.putStrLn ("source: " <> renderConfigSource configSource)+    Text.IO.putStr (renderConfig config)+  ConfigPath -> do+    configSource <- findConfigSource+    Text.IO.putStrLn (renderConfigSource configSource)+  ConfigInit global -> do+    target <- if global then xdgConfigPath else projectConfigPath+    exists <- doesFileExist target+    if exists+      then dieText ("Refusing to overwrite existing config: " <> Text.pack target)+      else do+        createDirectoryIfMissing True (FilePath.takeDirectory target)+        Text.IO.writeFile target exampleConfigText+        Text.IO.putStrLn ("Wrote " <> Text.pack target)+  -- No flags of its own, so what it prints is what `okf assist` would use when+  -- invoked with none.+  ConfigAgent -> do+    resolutions <-+      traverse+        (\agentCommand -> (agentCommand,) <$> resolveAgentOrDie agentCommand noAgentOverrides)+        allAgentCommands+    Text.IO.putStr (renderAgentResolution resolutions)++loadConfigOrDie :: IO OkfConfig+loadConfigOrDie = fst <$> loadConfigWithSourceOrDie++loadConfigWithSourceOrDie :: IO (OkfConfig, ConfigSource)+loadConfigWithSourceOrDie = do+  result <- loadOkfConfig+  case result of+    Left err -> dieText ("Failed to load config: " <> err)+    Right loaded -> pure loaded++-- | Resolve one agent command's settings across flags, environment, and both+-- configuration scopes, terminating with a message if any input is unusable.+resolveAgentOrDie :: AgentCommandName -> AgentOverrides -> IO ResolvedAgent+resolveAgentOrDie agentCommand flagOverrides = do+  envOverrides <- readAgentEnvOverrides+  scopes <- loadAgentScopes+  case scopes of+    Left err -> dieText err+    Right (localAgent, globalAgent) ->+      pure (resolveAgent agentCommand flagOverrides envOverrides localAgent globalAgent)++-- | Read the @OKF_AGENT_*@ variables. A variable set to blank is treated as+-- unset, matching how a blank configuration key is treated, so clearing one+-- with @OKF_AGENT_MODEL=@ works rather than failing to parse.+readAgentEnvOverrides :: IO AgentOverrides+readAgentEnvOverrides = do+  providerValue <- lookupParsedEnv ProviderField parseOkfProvider+  modelValue <- lookupEnvText ModelField+  effortValue <- lookupParsedEnv EffortField parseOkfEffort+  systemPromptValue <- lookupEnvText SystemPromptField+  pure+    AgentOverrides+      { provider = providerValue,+        model = modelValue,+        effort = effortValue,+        systemPrompt = systemPromptValue+      }+  where+    lookupEnvText agentField = do+      raw <- lookupEnv (agentFieldEnvVar agentField)+      pure (nonBlankEnv =<< raw)++    lookupParsedEnv agentField parse = do+      raw <- lookupEnv (agentFieldEnvVar agentField)+      case nonBlankEnv =<< raw of+        Nothing -> pure Nothing+        Just rawValue ->+          case parse rawValue of+            -- Name the variable, so a user with four of them set knows which to fix.+            Left message -> dieText (Text.pack (agentFieldEnvVar agentField) <> ": " <> message)+            Right parsed -> pure (Just parsed)++    nonBlankEnv raw =+      let stripped = Text.strip (Text.pack raw)+       in if Text.null stripped then Nothing else Just stripped++-- | Legacy singular environment override for the registry @okf profile@ reads.+profileRegistryEnvVar :: String+profileRegistryEnvVar = "OKF_PROFILE_REGISTRY"++-- | Plural environment override. JSON is used because registry references may+-- themselves contain colons in URLs and integrity hashes.+profileRegistriesEnvVar :: String+profileRegistriesEnvVar = "OKF_PROFILE_REGISTRIES"++runProfile :: ProfileCommand -> IO ()+runProfile = \case+  ProfileList options -> runProfileList options+  ProfileSources options -> runProfileSources options+  ProfileShow options -> runProfileShow options+  ProfileDocument options -> runProfileDocument options++-- | Decode the plural environment value. Aeson decodes directly to @[Text]@,+-- so a non-array or non-string member is rejected rather than coerced. Blank+-- elements are discarded and exact duplicates keep their first occurrence.+parseProfileRegistriesEnv :: Text -> Either Text [Text]+parseProfileRegistriesEnv encoded =+  case Aeson.eitherDecodeStrict' (Text.Encoding.encodeUtf8 encoded) of+    Left err -> Left (Text.pack err)+    Right references ->+      Right+        ( List.nub+            (filter (not . Text.null) (map Text.strip (references :: [Text])))+        )++-- | Profile source precedence: repeated @--registry@ flags, then the plural+-- JSON environment value, then the legacy singular environment value, then the+-- effective configuration. Only the winning layer contributes sources.+-- Configuration is read only when needed, so a broken file cannot stop an+-- explicit invocation.+resolveProfileSources :: [Text] -> IO [ResolvedProfileSource]+resolveProfileSources explicit+  | not (null explicit) = resolveReferences RegistryFlagOrigin explicit+  | otherwise = do+      plural <- lookupEnv profileRegistriesEnvVar+      case nonBlankEnvironment plural of+        Just encoded ->+          case parseProfileRegistriesEnv encoded of+            Left err ->+              dieText+                ( Text.pack profileRegistriesEnvVar+                    <> ": expected a JSON array of strings: "+                    <> err+                )+            Right references -> resolveReferences RegistriesEnvironmentOrigin references+        Nothing -> do+          legacyVariable <- lookupEnv profileRegistryEnvVar+          case nonBlankEnvironment legacyVariable of+            Just reference -> resolveReferences LegacyRegistryEnvironmentOrigin [reference]+            Nothing -> do+              (OkfConfig {profiles = ProfileSettings {registries}}, configSource) <- loadConfigWithSourceOrDie+              resolveReferences (profileConfigOrigin configSource) registries+  where+    nonBlankEnvironment Nothing = Nothing+    nonBlankEnvironment (Just raw) =+      let stripped = Text.strip (Text.pack raw)+       in if Text.null stripped then Nothing else Just stripped++    profileConfigOrigin = \case+      SourceEnv path -> ProfileConfigOrigin path+      SourceProject path -> ProfileConfigOrigin path+      SourceXdg path -> ProfileConfigOrigin path+      SourceDot path -> ProfileConfigOrigin path+      SourceDefaults -> BuiltInRegistryOrigin++-- | Resolve the winning registry layer and append network-silent local+-- descriptors unless the caller explicitly requested registry-only behavior.+resolveEffectiveProfileSources :: [Text] -> Bool -> IO [ResolvedProfileSource]+resolveEffectiveProfileSources explicit omitLocal = do+  registries <- resolveProfileSources explicit+  if omitLocal+    then pure registries+    else do+      ProfileDiscovery {searchRoots, descriptorPaths} <- discoverAvailableProfiles+      pure+        ( registries+            <> [ ResolvedProfileSource+                   { resolvedSource = DescriptorSource path,+                     sourceOrigin = ProfileDiscoveryOrigin searchRoots+                   }+               | path <- descriptorPaths+               ]+        )++resolveReferences :: ProfileSourceOrigin -> [Text] -> IO [ResolvedProfileSource]+resolveReferences origin references = do+  resolved <-+    traverse+      ( \reference -> do+          registryRef <- resolveRegistryRef reference+          pure+            ResolvedProfileSource+              { resolvedSource = RegistrySource reference registryRef,+                sourceOrigin = origin+              }+      )+      (filter (not . Text.null) (map Text.strip references))+  pure (List.nubBy (\left right -> resolvedSource left == resolvedSource right) resolved)++-- | Load sources for a survey command. Failures are reported but do not hide+-- successful entries, and the command succeeds whenever at least one profile+-- was enumerated.+loadProfileSourcesForSurvey :: [ResolvedProfileSource] -> IO ([SourceFailure], [SourcedProfile])+loadProfileSourcesForSurvey [] = dieText "No profile sources selected."+loadProfileSourcesForSurvey resolved = do+  loaded@(failures, profiles) <- loadProfileSources (map resolvedSource resolved)+  traverse_ (Text.IO.hPutStrLn stderr . renderSourceFailure) failures+  when (null profiles) $+    dieText "No profiles found in the selected sources."+  pure loaded++-- | Load sources for a named lookup. Any failed source makes uniqueness+-- unknowable, so lookup fails closed before examining the surviving entries.+loadProfileSourcesForNamedLookup :: [ResolvedProfileSource] -> IO [SourcedProfile]+loadProfileSourcesForNamedLookup [] = dieText "No profile sources selected."+loadProfileSourcesForNamedLookup resolved = do+  (failures, profiles) <- loadProfileSources (map resolvedSource resolved)+  unless (null failures) $+    dieText (Text.intercalate "\n" (map renderSourceFailure failures))+  when (null profiles) $+    dieText "No profiles found in the selected sources."+  pure profiles++renderSourceFailure :: SourceFailure -> Text+renderSourceFailure SourceFailure {failedSource, failureReason} =+  case failedSource of+    RegistrySource _reference _resolved ->+      renderRegistryLoadError (renderProfileSourceReference failedSource) failureReason+    DescriptorSource _path ->+      "Failed to load local profile descriptor "+        <> renderProfileSourceReference failedSource+        <> ": "+        <> failureReason++-- | A load failure is usually a mistyped path or a missing network, so say what+-- a reference may be and how to work offline.+renderRegistryLoadError :: Text -> Text -> Text+renderRegistryLoadError reference err =+  Text.unlines+    [ "Failed to load profile registry " <> reference <> ": " <> err,+      "A registry reference may be a path to a Dhall file, a directory holding package.dhall, or a",+      "Dhall expression such as a hash-pinned URL. Remote references need network access on first",+      "use; pass --registry with a local checkout to work offline."+    ]++runProfileList :: ProfileListOptions -> IO ()+runProfileList ProfileListOptions {registryRefs, noLocal, json, wide} = do+  resolved <- resolveEffectiveProfileSources registryRefs noLocal+  (_failures, profiles) <- loadProfileSourcesForSurvey resolved+  if json+    then LazyByteString.putStrLn (Aeson.encode (registryListJson resolved profiles))+    else+      traverse_+        Text.IO.putStrLn+        (renderRegistryTable (if wide then WideTable else CompactTable) profiles)++registryListJson :: [ResolvedProfileSource] -> [SourcedProfile] -> Aeson.Value+registryListJson resolved profiles =+  Aeson.object+    ( legacyRegistryField+        <> [ "sources" Aeson..= map resolvedSourceJson resolved,+             "profiles"+               Aeson..= [ Aeson.object+                            [ "source" Aeson..= profileSourceJson source,+                              "export" Aeson..= export,+                              "profile" Aeson..= spec+                            ]+                        | SourcedProfile {source, entry = RegistryEntry {export, spec}} <- profiles+                        ]+           ]+    )+  where+    legacyRegistryField =+      case resolved of+        [ResolvedProfileSource {resolvedSource = singleSource@(RegistrySource _reference _resolved)}] ->+          ["registry" Aeson..= renderProfileSourceReference singleSource]+        _ -> []++    profileSourceJson profileSource =+      case List.find ((== profileSource) . resolvedSource) resolved of+        Just resolvedProfileSource -> resolvedSourceJson resolvedProfileSource+        Nothing -> sourceJson profileSource Aeson.Null++resolvedSourceJson :: ResolvedProfileSource -> Aeson.Value+resolvedSourceJson ResolvedProfileSource {resolvedSource, sourceOrigin} =+  sourceJson resolvedSource (profileSourceOriginJson sourceOrigin)++sourceJson :: ProfileSource -> Aeson.Value -> Aeson.Value+sourceJson profileSource origin =+  Aeson.object+    [ "kind" Aeson..= sourceKind profileSource,+      "label" Aeson..= renderProfileSourceLabel profileSource,+      "reference" Aeson..= renderProfileSourceReference profileSource,+      "origin" Aeson..= origin+    ]+  where+    sourceKind (RegistrySource _reference _resolved) = "registry" :: Text+    sourceKind (DescriptorSource _path) = "descriptor"++profileSourceOriginJson :: ProfileSourceOrigin -> Aeson.Value+profileSourceOriginJson = \case+  RegistryFlagOrigin -> Aeson.object ["kind" Aeson..= ("flag" :: Text), "name" Aeson..= ("--registry" :: Text)]+  RegistriesEnvironmentOrigin -> Aeson.object ["kind" Aeson..= ("environment" :: Text), "name" Aeson..= profileRegistriesEnvVar]+  LegacyRegistryEnvironmentOrigin -> Aeson.object ["kind" Aeson..= ("environment" :: Text), "name" Aeson..= profileRegistryEnvVar]+  ProfileConfigOrigin path -> Aeson.object ["kind" Aeson..= ("config" :: Text), "path" Aeson..= path]+  BuiltInRegistryOrigin -> Aeson.object ["kind" Aeson..= ("built-in" :: Text)]+  ProfileDiscoveryOrigin roots -> Aeson.object ["kind" Aeson..= ("discovery" :: Text), "roots" Aeson..= roots]++runProfileSources :: ProfileSourcesOptions -> IO ()+runProfileSources ProfileSourcesOptions {registryRefs, noLocal, json, checkLatest} = do+  resolved <- resolveEffectiveProfileSources registryRefs noLocal+  resolutions <- traverse inspectProfileSource resolved+  freshness <-+    if checkLatest+      then checkProfileRegistryFreshness+      else pure FreshnessNotChecked+  if json+    then LazyByteString.putStrLn (Aeson.encode (profileSourcesJson resolutions freshness))+    else Text.IO.putStr (renderProfileSourceResolution resolutions freshness)+  unless (any sourcePublishedProfiles resolutions) exitFailure++inspectProfileSource :: ResolvedProfileSource -> IO ProfileSourceResolution+inspectProfileSource resolved@ResolvedProfileSource {resolvedSource} = do+  loaded <- loadProfileSourceDetailed resolvedSource+  pure+    ProfileSourceResolution+      { resolvedProfileSource = resolved,+        profileSourceStatus =+          case loaded of+            Left profileSourceError -> ProfileSourceFailed profileSourceError+            Right profiles -> ProfileSourceLoaded (length profiles)+      }++sourcePublishedProfiles :: ProfileSourceResolution -> Bool+sourcePublishedProfiles ProfileSourceResolution {profileSourceStatus = ProfileSourceLoaded count} = count > 0+sourcePublishedProfiles ProfileSourceResolution {profileSourceStatus = ProfileSourceFailed _error} = False++-- | The source inspection table and its precedence legend are pure so the+-- command can be pinned without evaluating Dhall or consulting the network.+renderProfileSourceResolution :: [ProfileSourceResolution] -> ProfileFreshness -> Text+renderProfileSourceResolution resolutions freshness =+  Text.unlines (map renderRow rows <> ["", renderFreshness freshness, ""] <> profileSourcePrecedence)+  where+    rows = sourceHeader : map resolutionRow resolutions+    sourceHeader = ["SOURCE", "ORIGIN", "STATUS", "PROFILES"]+    widths = [maximum (0 : map (Text.length . (!! column)) rows) | column <- [0 .. 3]]+    renderRow cells = Text.stripEnd (Text.intercalate "  " (zipWith padRight widths cells))+    padRight width cell = cell <> Text.replicate (max 0 (width - Text.length cell)) " "++resolutionRow :: ProfileSourceResolution -> [Text]+resolutionRow+  ProfileSourceResolution+    { resolvedProfileSource = resolved@ResolvedProfileSource {sourceOrigin},+      profileSourceStatus+    } =+    [ profileSourceInspectionLabel resolved,+      "[" <> profileSourceOriginLabel sourceOrigin <> "]",+      case profileSourceStatus of+        ProfileSourceLoaded _count -> "loaded"+        ProfileSourceFailed profileSourceError ->+          "failed (" <> profileSourceLoadErrorCategory profileSourceError <> ")",+      case profileSourceStatus of+        ProfileSourceLoaded count -> Text.pack (show count)+        ProfileSourceFailed _error -> "0"+    ]++profileSourceInspectionLabel :: ResolvedProfileSource -> Text+profileSourceInspectionLabel ResolvedProfileSource {resolvedSource, sourceOrigin} =+  case (resolvedSource, sourceOrigin) of+    (RegistrySource reference _registryRef, BuiltInRegistryOrigin) ->+      case pinnedRegistryTag reference of+        Just tag -> "okf-profiles " <> tag <> " (pinned)"+        Nothing -> renderProfileSourceReference resolvedSource+    (RegistrySource _reference _registryRef, _) -> renderProfileSourceReference resolvedSource+    (DescriptorSource _path, _) -> renderProfileSourceReference resolvedSource++profileSourceOriginLabel :: ProfileSourceOrigin -> Text+profileSourceOriginLabel = \case+  RegistryFlagOrigin -> "flag"+  RegistriesEnvironmentOrigin -> "env: " <> Text.pack profileRegistriesEnvVar+  LegacyRegistryEnvironmentOrigin -> "env: " <> Text.pack profileRegistryEnvVar+  ProfileConfigOrigin path -> "config: " <> Text.pack path+  BuiltInRegistryOrigin -> "built-in default"+  ProfileDiscoveryOrigin roots ->+    "discovery: " <> Text.intercalate ":" (map Text.pack roots)++profileSourcePrecedence :: [Text]+profileSourcePrecedence =+  [ "Precedence, highest first:",+    "  1. --registry flag (repeatable); the flag list replaces every other registry layer",+    "  2. OKF_PROFILE_REGISTRIES (JSON array)",+    "  3. OKF_PROFILE_REGISTRY (legacy single reference)",+    "  4. profiles.registries in the effective config file",+    "  5. built-in default when the decoded configuration has no profiles block",+    "Within a list, order is preserved and exact duplicates are dropped. Every source is",+    "enumerated; sources merge rather than replace. Local descriptors follow the winning",+    "registry list unless --no-local is passed. Survey commands report partial failure; named",+    "lookup fails closed."+  ]++profileSourcesJson :: [ProfileSourceResolution] -> ProfileFreshness -> Aeson.Value+profileSourcesJson resolutions freshness =+  Aeson.object+    [ "sources" Aeson..= map profileSourceResolutionJson resolutions,+      "pinnedVersion" Aeson..= pinnedRegistryTag defaultRegistryReference,+      "freshness" Aeson..= profileFreshnessJson freshness,+      "precedence" Aeson..= profileSourcePrecedence+    ]++profileSourceResolutionJson :: ProfileSourceResolution -> Aeson.Value+profileSourceResolutionJson+  ProfileSourceResolution+    { resolvedProfileSource,+      profileSourceStatus+    } =+    extendObject fields (resolvedSourceJson resolvedProfileSource)+    where+      fields =+        case profileSourceStatus of+          ProfileSourceLoaded count ->+            [ ("status", Aeson.String "loaded"),+              ("profileCount", Aeson.toJSON count)+            ]+          ProfileSourceFailed profileSourceError ->+            [ ("status", Aeson.String "failed"),+              ("profileCount", Aeson.toJSON (0 :: Int)),+              ( "error",+                Aeson.object+                  [ "category" Aeson..= profileSourceLoadErrorCategory profileSourceError,+                    "message" Aeson..= renderProfileSourceLoadError profileSourceError+                  ]+              )+            ]++extendObject :: [(Text, Aeson.Value)] -> Aeson.Value -> Aeson.Value+extendObject fields (Aeson.Object object) =+  Aeson.Object+    ( foldr+        (\(key, fieldValue) accumulated -> KeyMap.insert (AesonKey.fromText key) fieldValue accumulated)+        object+        fields+    )+extendObject _fields originalValue = originalValue++profileFreshnessJson :: ProfileFreshness -> Aeson.Value+profileFreshnessJson = \case+  FreshnessNotChecked -> Aeson.object ["status" Aeson..= ("not-checked" :: Text)]+  FreshnessCurrent pinned ->+    Aeson.object ["status" Aeson..= ("current" :: Text), "pinnedVersion" Aeson..= pinned]+  FreshnessOutdated pinned latest ->+    Aeson.object+      [ "status" Aeson..= ("outdated" :: Text),+        "pinnedVersion" Aeson..= pinned,+        "latestVersion" Aeson..= latest,+        "refreshCommand" Aeson..= ("scripts/refresh-default-registry.sh " <> latest)+      ]+  FreshnessUnavailable message ->+    Aeson.object ["status" Aeson..= ("unavailable" :: Text), "message" Aeson..= message]++renderFreshness :: ProfileFreshness -> Text+renderFreshness = \case+  FreshnessNotChecked ->+    case pinnedRegistryTag defaultRegistryReference of+      Just pinned ->+        "Pinned catalogue: okf-profiles "+          <> pinned+          <> " (not checked; pass --check-latest to query upstream tags)"+      Nothing -> "Pinned catalogue: version could not be parsed from the built-in reference"+  FreshnessCurrent pinned -> "Freshness: pinned okf-profiles " <> pinned <> " is current."+  FreshnessOutdated pinned latest ->+    "Freshness: pinned okf-profiles "+      <> pinned+      <> " is behind "+      <> latest+      <> "; review and run scripts/refresh-default-registry.sh "+      <> latest+  FreshnessUnavailable message -> "Freshness: could not check upstream tags: " <> message++parseReleaseVersionTag :: Text -> Maybe ReleaseVersion+parseReleaseVersionTag raw = do+  version <- Text.stripPrefix "v" (Text.strip raw)+  case Text.splitOn "." version of+    [majorText, minorText, patchText] ->+      ReleaseVersion+        <$> parseComponent majorText+        <*> parseComponent minorText+        <*> parseComponent patchText+    _ -> Nothing+  where+    parseComponent component+      | Text.null component || not (Text.all isDigit component) = Nothing+      | otherwise = readMaybe (Text.unpack component)++renderReleaseVersion :: ReleaseVersion -> Text+renderReleaseVersion (ReleaseVersion major minor patch) =+  "v"+    <> Text.pack (show major)+    <> "."+    <> Text.pack (show minor)+    <> "."+    <> Text.pack (show patch)++pinnedRegistryTag :: Text -> Maybe Text+pinnedRegistryTag reference = do+  expression <- listToMaybe (Text.words reference)+  listToMaybe+    [ renderReleaseVersion version+    | segment <- Text.splitOn "/" expression,+      Just version <- [parseReleaseVersionTag segment]+    ]++latestReleaseTag :: Text -> Maybe Text+latestReleaseTag output =+  snd+    <$> listToMaybe+      ( reverse+          ( List.sortOn+              fst+              [ (version, renderReleaseVersion version)+              | line <- Text.lines output,+                ref <- take 1 (drop 1 (Text.words line)),+                Just tag <- [Text.stripPrefix "refs/tags/" ref],+                Just version <- [parseReleaseVersionTag tag]+              ]+          )+      )++checkProfileRegistryFreshness :: IO ProfileFreshness+checkProfileRegistryFreshness =+  case pinnedRegistryTag defaultRegistryReference of+    Nothing -> pure (FreshnessUnavailable "the pinned tag could not be parsed")+    Just pinned -> do+      result <-+        try+          ( readProcessWithExitCode+              "git"+              [ "ls-remote",+                "--refs",+                "--sort=-version:refname",+                "--tags",+                "https://github.com/shinzui/okf-profiles.git",+                "v*"+              ]+              ""+          )+      pure $+        case result of+          Left (_exception :: IOException) ->+            FreshnessUnavailable "git could not query https://github.com/shinzui/okf-profiles.git"+          Right (ExitFailure _code, _output, _errorOutput) ->+            FreshnessUnavailable "git could not query https://github.com/shinzui/okf-profiles.git"+          Right (ExitSuccess, output, _errorOutput) ->+            case latestReleaseTag (Text.pack output) of+              Nothing -> FreshnessUnavailable "upstream returned no well-formed vMAJOR.MINOR.PATCH tags"+              Just latest ->+                case (parseReleaseVersionTag pinned, parseReleaseVersionTag latest) of+                  (Just pinnedVersion, Just latestVersion)+                    | latestVersion > pinnedVersion -> FreshnessOutdated pinned latest+                  _ -> FreshnessCurrent pinned++-- | A deterministic two-line row per profile. Compact mode uses fixed column+-- budgets whose separators sum to 100 Unicode code points, then caps the+-- indented description to 98. Wide mode derives widths from the full identity+-- values and keeps the complete normalized description. Unicode code points+-- are intentionally used instead of terminal display cells so rendering stays+-- pure and terminal-independent; combining and double-width glyphs may not+-- visually occupy exactly one cell.+renderRegistryTable :: RegistryTableMode -> [SourcedProfile] -> [Text]+renderRegistryTable mode profiles =+  renderIdentity headerRow : concatMap renderEntry entryRows+  where+    headerRow = ["SOURCE", "EXPORT", "NAME", "OKF", "TYPES", "ID FIELD"]+    entryRow+      SourcedProfile+        { source,+          entry =+            RegistryEntry+              { export = exportPath,+                spec = ProfileSpec {name, description, okfVersion, idField, types = typeRules}+              }+        } =+        ( [ renderProfileSourceLabel source,+            displayExport exportPath,+            name,+            okfVersion,+            Text.pack (show (length typeRules)),+            fromMaybe "-" idField+          ],+          fromMaybe "-" description+        )++    entryRows = map entryRow profiles+    identityRows = headerRow : map fst entryRows+    widths =+      case mode of+        CompactTable -> [14, 28, 28, 3, 5, 12]+        WideTable ->+          [maximum (0 : map (Text.length . (!! column)) identityRows) | column <- [0 .. 5]]+    padders = [padRight, padRight, padRight, padLeft, padLeft, padRight]++    renderEntry (identity, description) =+      [renderIdentity identity, "  " <> renderDescription description]++    renderIdentity cells =+      Text.stripEnd+        ( Text.intercalate+            "  "+            (zipWith3 id padders widths (zipWith capIdentity widths cells))+        )++    capIdentity width cell =+      case mode of+        CompactTable -> capText width cell+        WideTable -> cell++    renderDescription descriptionText =+      case mode of+        CompactTable -> capText 98 (oneLine descriptionText)+        WideTable -> oneLine descriptionText++    oneLine = Text.unwords . Text.words++    padRight width cell = cell <> Text.replicate (max 0 (width - Text.length cell)) " "+    padLeft width cell = Text.replicate (max 0 (width - Text.length cell)) " " <> cell++capText :: Int -> Text -> Text+capText width textValue+  | width <= 0 = ""+  | Text.length textValue <= width = textValue+  | otherwise = Text.take (width - 1) textValue <> "…"++-- | An entry found at the registry root has no export path of its own.+displayExport :: Text -> Text+displayExport exportPath+  | Text.null exportPath = rootExportLabel+  | otherwise = exportPath++runProfileShow :: ProfileShowOptions -> IO ()+runProfileShow ProfileShowOptions {registryRefs, export = requestedExport, noLocal, json} = do+  resolved <- resolveEffectiveProfileSources registryRefs noLocal+  profiles <- loadProfileSourcesForNamedLookup resolved+  SourcedProfile+    { source,+      entry = RegistryEntry {export = foundExport, spec}+    } <-+    selectEntry profiles requestedExport+  if json+    then LazyByteString.putStrLn (Aeson.encode spec)+    else do+      traverse_ Text.IO.putStrLn (renderProfileDetail foundExport spec)+      traverse_ Text.IO.putStrLn (profileUsage source foundExport)+  where+    profileUsage (RegistrySource _reference ref) foundExport = renderProfileUsage ref foundExport+    profileUsage (DescriptorSource path) _foundExport = renderProfileUsage (RegistryFile path) ""++-- | Pick the profile to show. With no @EXPORT@ argument a single-profile+-- registry needs no disambiguation; otherwise the available exports are listed,+-- which is also what an unknown export reports.+selectEntry :: [SourcedProfile] -> Maybe Text -> IO SourcedProfile+selectEntry profiles requested = either dieText pure (selectSourcedProfile profiles requested)++-- | Resolve one profile from a complete, successfully loaded source set. The+-- pure result keeps ambiguity behavior directly testable; command policy turns+-- the 'Left' into exit status 1.+selectSourcedProfile :: [SourcedProfile] -> Maybe Text -> Either Text SourcedProfile+selectSourcedProfile profiles = \case+  Nothing -> case profiles of+    [single] -> Right single+    _ ->+      Left+        ( "The selected sources publish more than one profile; name one.\n"+            <> availableExports profiles+        )+  Just requested -> case findSourcedProfiles requested profiles of+    [single] -> Right single+    [] ->+      Left+        ( "No profile named "+            <> requested+            <> " in the selected sources"+            <> "\n"+            <> availableExports profiles+        )+    collisions ->+      Left+        ( "Profile export "+            <> requested+            <> " is ambiguous; it is published by:\n"+            <> Text.unlines+              [ "  " <> renderProfileSourceReference source+              | SourcedProfile {source} <- collisions+              ]+            <> "Rerun with --no-local and exactly one intended --registry REFERENCE."+            <> " If REFERENCE is a descriptor file, omit EXPORT."+        )+  where+    availableExports found =+      "Available exports: "+        <> Text.intercalate+          ", "+          [ displayExport exportPath <> " (" <> renderProfileSourceLabel source <> ")"+          | SourcedProfile {source, entry = RegistryEntry {export = exportPath}} <- found+          ]++-- | Generate an OKF bundle documenting a profile.+--+-- Preview by default: nothing is written unless both @--out DIR@ and @--write@+-- are given. The write rules are recorded in+-- @docs\/adr\/6-generated-profile-documentation.md@: the command overwrites+-- exactly the files it generates, never deletes, and reports concepts already+-- in the destination that this run did not generate.+runProfileDocument :: ProfileDocumentOptions -> IO ()+runProfileDocument+  ProfileDocumentOptions+    { registryRefs,+      export = requestedExport,+      profilePath,+      noLocal,+      outputPath,+      write,+      timestamp,+      generatedBy,+      generatedAt,+      okfVersion+    } = do+    when (isJust profilePath && (isJust requestedExport || not (null registryRefs))) $+      dieText "Pass --profile PATH by itself, or select a registry profile with [--registry REGISTRY] [EXPORT]."+    when (write && isNothing outputPath) $+      dieText "--write needs a destination; pass --out DIR."+    -- Parse the requested version before anything is written, so a malformed+    -- one fails without leaving a half-generated bundle behind.+    versionOverride <- traverse requestedVersion okfVersion+    compiled <- resolveCompiledProfile+    concepts <- case renderProfileDocumentation documentationOptions compiled of+      Left documentationError -> dieText (renderDocumentationError documentationError)+      Right rendered -> pure rendered+    case (write, outputPath) of+      (True, Just destination) -> writeProfileDocumentation versionOverride destination concepts+      _ -> previewProfileDocumentation outputPath concepts+    where+      -- Same diagnostic as @okf index --okf-version@, so the two commands+      -- reject a malformed version identically.+      requestedVersion rawVersion =+        case parseOkfVersion rawVersion of+          Just version -> pure version+          Nothing -> dieText ("Not an OKF version of the form MAJOR.MINOR: " <> rawVersion)++      documentationOptions =+        DocumentationOptions+          { rootConceptId = rootConceptId defaultDocumentationOptions,+            typeDirectory = typeDirectory defaultDocumentationOptions,+            timestamp = timestamp,+            -- 'parseActor' is total, so @--generated-by Nadeem@ is preserved+            -- verbatim as an unclassified actor rather than rejected:+            -- specification §11 forbids failing on a malformed optional field.+            generated =+              Just+                ( Generated+                    (maybe defaultDocumentationActor parseActor generatedBy)+                    generatedAt+                )+          }++      resolveCompiledProfile = do+        (label, spec) <- case profilePath of+          Just path -> do+            loadedSpec <- loadProfileOrExit path+            pure (Text.pack path, loadedSpec)+          Nothing+            | isNothing requestedExport && null registryRefs -> do+                path <- resolveProfilePath Nothing+                loadedSpec <- loadProfileOrExit path+                pure (Text.pack path, loadedSpec)+            | otherwise -> do+                resolved <- resolveEffectiveProfileSources registryRefs noLocal+                profiles <- loadProfileSourcesForNamedLookup resolved+                SourcedProfile {entry = RegistryEntry {export = foundExport, spec}} <-+                  selectEntry profiles requestedExport+                pure (displayExport foundExport, spec)+        compileProfileOrExit label spec++-- | Print every file the command would generate, in the same shape+-- @okf index@ previews its own output, then say what would happen on+-- @--write@. Touches nothing.+previewProfileDocumentation :: Maybe FilePath -> [Concept] -> IO ()+previewProfileDocumentation destination concepts = do+  mapM_+    (\concept -> renderIndexPreview (conceptSourcePath concept, serializeConcept concept))+    concepts+  Text.IO.putStrLn summary+  where+    summary =+      case destination of+        Just directory ->+          "(preview only; pass --write to write these "+            <> countPhrase (length concepts) "file"+            <> " to "+            <> Text.pack directory+            <> ")"+        Nothing ->+          "(preview only; pass --out DIR --write to write these "+            <> countPhrase (length concepts) "file"+            <> ")"++-- | Write the generated bundle and regenerate the destination's index files.+--+-- Overwrites exactly the concepts it generated and never deletes, so a+-- destination holding pages from an earlier profile keeps them. Those are+-- reported rather than silently left to rot.+--+-- A 'Just' version override declares that OKF version in the destination's root+-- index; 'Nothing' preserves whatever declaration is already there, so+-- regenerating into a directory that declares a version cannot strip it.+writeProfileDocumentation :: Maybe OkfVersion -> FilePath -> [Concept] -> IO ()+writeProfileDocumentation versionOverride destination concepts = do+  -- A destination that does not exist yet walks as an IO error, which here+  -- means "no pre-existing concepts" rather than a failure: writeBundle+  -- creates the directory.+  existing <- walkBundle destination+  let generated = Set.fromList (map conceptIdOf concepts)+      stale =+        [ conceptId+        | concept <- either (const []) Prelude.id existing,+          let conceptId = conceptIdOf concept,+          not (Set.member conceptId generated)+        ]+  writeBundle destination concepts+  indexes <- renderBundleIndexesWith versionOverride destination+  indexCount <- case indexes of+    Left bundleError -> dieText (renderBundleError bundleError)+    -- Count distinct paths: 'renderBundleIndexes' yields the bundle root twice,+    -- once as "" and once as ".", which write the same file.+    Right rendered -> pure (length (List.nub (map (FilePath.normalise . fst) rendered)))+  written <- writeBundleIndexesWith versionOverride destination+  case written of+    Left bundleError -> dieText (renderBundleError bundleError)+    Right () -> pure ()+  Text.IO.putStrLn+    ( "Wrote "+        <> countPhrase (length concepts) "concept"+        <> " and "+        <> countPhrase indexCount "index.md file"+        <> " to "+        <> Text.pack destination+    )+  unless (null stale) $+    Text.IO.putStrLn+      ( "Note: "+          <> Text.pack destination+          <> " also contains "+          <> countPhrase (length stale) "concept"+          <> " this profile did not generate ("+          <> Text.intercalate ", " (map renderConceptId stale)+          <> "). Left untouched; delete them if their type rules were removed."+      )++-- | @1 concept@ but @2 concepts@, so summary lines read as English.+countPhrase :: Int -> Text -> Text+countPhrase count noun =+  Text.pack (show count) <> " " <> noun <> (if count == 1 then "" else "s")++renderDocumentationError :: DocumentationError -> Text+renderDocumentationError = \case+  InvalidRootConceptId raw err -> renderConceptIdError raw err+  InvalidTypeDirectory raw err -> renderConceptIdError raw err++-- | One profile's complete rule set. Every optional field prints as @(none)@+-- rather than being omitted, so the output shape does not change between+-- profiles and stays reliable to eyeball or grep. Type rules print in the order+-- the profile declares them, since that order is the author's.+renderProfileDetail :: Text -> ProfileSpec -> [Text]+renderProfileDetail+  exportPath+  ProfileSpec+    { name,+      description,+      okfVersion,+      frontmatter,+      allowUnknownTypes,+      allowUnknownFields,+      idField,+      requireBundleVersion,+      types = typeRules+    } =+    [ "export: " <> displayExport exportPath,+      "name: " <> name,+      "description: " <> renderOptional description,+      "okfVersion: " <> okfVersion,+      "requireBundleVersion: " <> renderOptional requireBundleVersion,+      "allowUnknownTypes: " <> renderFlag allowUnknownTypes,+      "allowUnknownFields: " <> renderFlag allowUnknownFields,+      "idField: " <> renderOptional idField+    ]+      <> renderPresenceLists "" frontmatter+      <> concatMap renderTypeRule typeRules+    where+      -- The three presence lists always print together and in the same order, at+      -- profile scope and under every type rule, so the effective policy for one+      -- key is readable in one place.+      renderPresenceLists indent rules =+        renderFieldRules indent "frontmatter.required" (rules ^. #required)+          <> renderFieldRules indent "frontmatter.recommended" (rules ^. #recommended)+          <> renderFieldRules indent "frontmatter.optional" (rules ^. #optional)++      -- A field's prose cannot share a comma-joined line with its neighbours, so+      -- a non-empty list becomes a headed block. An empty list keeps the+      -- single-line @(none)@ form the other optional fields use.+      renderFieldRules indent label [] = [indent <> label <> ": " <> renderList []]+      renderFieldRules indent label rules =+        (indent <> label <> ":")+          : concatMap (renderFieldRule indent) rules++      renderFieldRule indent rule =+        [ indent <> "  - " <> rule ^. #field <> ": " <> renderOptional (rule ^. #description),+          indent <> "    allowedValues: " <> renderVocabulary (rule ^. #allowedValues),+          indent <> "    cardinality: " <> renderCardinality (rule ^. #cardinality),+          indent <> "    format: " <> maybe "(none)" renderFieldFormat (rule ^. #format),+          indent <> "    reference: " <> maybe "(none)" renderHandleReferenceRule (rule ^. #reference),+          indent <> "    path: " <> maybe "(none)" renderPathReferenceRule (rule ^. #path),+          indent <> "    when: " <> maybe "(none)" renderCondition (rule ^. #when)+        ]+          -- The two nested shapes print together and in this order, matching+          -- 'Okf.Profile.Documentation.renderFieldRule', which is the other+          -- renderer of the same rule. @objectFields@ constrains the record that+          -- /is/ the value, so it reaches @executor.resource@; @elementFields@+          -- constrains the members of each element of a list, so it reaches+          -- @sources[].resource@. A reader comparing the two should not have to+          -- scroll past seven scalar lines to find the second one.+          <> renderNestedBlock indent "objectFields" (rule ^. #objectFields)+          <> renderNestedBlock indent "elementFields" (rule ^. #elementFields)++      renderNestedBlock indent label = \case+        Nothing -> [indent <> "    " <> label <> ": (none)"]+        Just nestedRules ->+          [indent <> "    " <> label <> ":"]+            <> renderNestedFieldRules (indent <> "      ") "required" (nestedRules ^. #required)+            <> renderNestedFieldRules (indent <> "      ") "recommended" (nestedRules ^. #recommended)+            <> renderNestedFieldRules (indent <> "      ") "optional" (nestedRules ^. #optional)++      renderNestedFieldRules indent label [] = [indent <> label <> ": " <> renderList []]+      renderNestedFieldRules indent label rules =+        (indent <> label <> ":") : concatMap (renderNestedFieldRule indent) rules++      renderNestedFieldRule indent rule =+        [ indent <> "  - " <> rule ^. #field <> ": " <> renderOptional (rule ^. #description),+          indent <> "    allowedValues: " <> renderVocabulary (rule ^. #allowedValues),+          indent <> "    cardinality: " <> renderCardinality (rule ^. #cardinality),+          indent <> "    format: " <> maybe "(none)" renderFieldFormat (rule ^. #format),+          indent <> "    path: " <> maybe "(none)" renderPathReferenceRule (rule ^. #path),+          indent <> "    when: " <> maybe "(none)" renderCondition (rule ^. #when)+        ]++      renderTypeRule+        TypeRule+          { type_ = ruleType,+            description = ruleDescription,+            frontmatter = typeFrontmatter,+            pathPattern,+            resourceScheme,+            requireSchemaSection,+            schemaColumns,+            idPrefix+          } =+          [ "",+            "type: " <> ruleType,+            "  description: " <> renderOptional ruleDescription+          ]+            <> renderPresenceLists "  " typeFrontmatter+            <> [ "  pathPattern: " <> renderOptional pathPattern,+                 "  resourceScheme: " <> renderOptional resourceScheme,+                 "  requireSchemaSection: " <> renderFlag requireSchemaSection,+                 "  schemaColumns: " <> renderList schemaColumns,+                 "  idPrefix: " <> renderOptional idPrefix+               ]++      renderFlag True = "true"+      renderFlag False = "false"+      renderOptional = fromMaybe "(none)"+      renderList [] = "(none)"+      renderList values = Text.intercalate ", " values+      renderVocabulary [] = "(any)"+      renderVocabulary values = Text.intercalate ", " values+      renderCondition FieldCondition {field = sourceField, hasValue} =+        sourceField <> " in [" <> Text.intercalate ", " hasValue <> "]"++-- | The two-line descriptor a user writes to consume the profile with+-- @okf validate --profile@. The reference is quoted in Dhall import syntax, not+-- as the user typed it: Dhall only accepts a path that starts with @.\/@,+-- @..\/@, @~\/@, or @\/@, so a bare relative path is prefixed to stay+-- copy-pasteable.+renderProfileUsage :: RegistryRef -> Text -> [Text]+renderProfileUsage ref exportPath =+  [ "",+    "Use it with:",+    "  let registry = " <> dhallImport ref,+    "  in  registry" <> selector+  ]+  where+    selector+      | Text.null exportPath = ""+      | otherwise = "." <> exportPath++    dhallImport (RegistryExpression expression) = expression+    dhallImport (RegistryFile path)+      | any (`Text.isPrefixOf` rendered) ["./", "../", "~/", "/"] = rendered+      | otherwise = "./" <> rendered+      where+        rendered = renderRegistryRef (RegistryFile path)++runValidate :: ValidateOptions -> IO ()+runValidate ValidateOptions {bundlePath, strictMode, profilePath, pickProfile, profileEnforce, logEnforce} = do+  when (pickProfile && isJust profilePath) $+    dieText "Pass either --profile PATH or --pick-profile, not both."+  resolvedBundle <- resolveBundlePath bundlePath+  resolvedProfilePath <-+    if pickProfile+      then Just <$> resolveProfilePath Nothing+      else traverse (resolveProfilePath . Just) profilePath+  concepts <- loadBundleOrExit resolvedBundle+  inventory <- loadBundleInventoryOrExit resolvedBundle+  logs <- loadLogsOrExit resolvedBundle+  declaration <- loadBundleVersionOrExit resolvedBundle+  let coreProfile = if strictMode then StrictAuthoring else PermissiveConformance+      coreErrors = validateBundle coreProfile declaration inventory concepts <> validateBundleLogs logs+  mapM_ (Text.IO.hPutStrLn stderr . renderBundleValidationError) coreErrors++  let logStalenessReport = logStaleness concepts logs+  mapM_ (Text.IO.hPutStrLn stderr . ("log: " <>) . renderLogStaleness) logStalenessReport++  profileViolations <- case resolvedProfilePath of+    Nothing -> pure []+    Just path -> do+      spec <- loadProfileOrExit path+      compiled <- compileProfileOrExit (Text.pack path) spec+      -- With the inventory rather than without it, so a @path@ rule naming+      -- §6.3's @references/attesters/revenue.py@ is resolved rather than+      -- accepted unchecked. This command walked a real directory, so it can+      -- answer the question.+      -- The version requirement first: a bundle that does not declare what the+      -- profile demands is context for every line below it.+      let violations =+            validateProfileVersion declaration compiled+              <> validateProfileWith inventory coreProfile compiled concepts+      mapM_ (Text.IO.hPutStrLn stderr . ("profile: " <>) . renderProfileViolation compiled concepts) violations+      pure violations++  let coreFailed = any bundleValidationErrorIsFailure coreErrors+      profileFailed = profileEnforce && not (null profileViolations)+      logFailed = logEnforce && (any bundleValidationErrorIsAdvisory coreErrors || not (null logStalenessReport))+  if coreFailed || profileFailed || logFailed+    then exitFailure+    else do+      Text.IO.putStrLn+        ( "OK: "+            <> Text.pack (show (length concepts))+            <> " concepts"+            <> renderDeclaredVersion declaration+        )+      unless (null profileViolations) $+        Text.IO.putStrLn+          ( "profile: "+              <> Text.pack (show (length profileViolations))+              <> " advisory deviation(s) (use --profile-enforce to fail)"+          )+      unless (null logStalenessReport) $+        Text.IO.putStrLn+          ( "log: "+              <> Text.pack (show (length logStalenessReport))+              <> " stale concept advisory/advisories (use --log-enforce to fail)"+          )++runIndex :: IndexOptions -> IO ()+runIndex IndexOptions {bundlePath, write, okfVersion} = do+  resolvedBundle <- resolveBundlePath bundlePath+  override <- traverse requestedVersion okfVersion+  if write+    then do+      result <- writeBundleIndexesWith override resolvedBundle+      case result of+        Left bundleError -> dieText (renderBundleError bundleError)+        Right () -> Text.IO.putStrLn "Wrote index.md files"+    else do+      indexes <- loadIndexesOrExit override resolvedBundle+      mapM_ renderIndexPreview indexes+  where+    requestedVersion rawVersion =+      case parseOkfVersion rawVersion of+        Just version -> pure version+        Nothing -> dieText ("Not an OKF version of the form MAJOR.MINOR: " <> rawVersion)++runLog :: LogOptions -> IO ()+runLog LogOptions {bundlePath, checkStale, sinceRef, logSub = LogPreview} = do+  resolvedBundle <- resolveBundlePath bundlePath+  logs <- loadLogsOrExit resolvedBundle+  mapM_ renderLogPreview logs+  let logErrors = validateBundleLogs logs+  mapM_ (Text.IO.hPutStrLn stderr . renderBundleValidationError) logErrors+  case sinceRef of+    Nothing -> pure ()+    Just ref -> runGitDriftCheck resolvedBundle ref logs+  logStalenessReport <-+    if checkStale+      then do+        concepts <- loadBundleOrExit resolvedBundle+        pure (logStaleness concepts logs)+      else pure []+  mapM_ (Text.IO.hPutStrLn stderr . ("log: " <>) . renderLogStaleness) logStalenessReport+  when (any bundleValidationErrorIsFailure logErrors) exitFailure+runLog LogOptions {bundlePath, logSub = LogAdd addOptions} = do+  resolvedBundle <- resolveBundlePath bundlePath+  runLogAdd resolvedBundle addOptions++runLogAdd :: FilePath -> LogAddOptions -> IO ()+runLogAdd bundlePath LogAddOptions {conceptId, kind, message, date} = do+  entryDate <- maybe todayDate pure date+  targetPath <- resolveLogTarget bundlePath conceptId+  let absolutePath = bundlePath </> targetPath+      entry = Log.LogEntry {Log.logKind = Just kind, Log.logText = message}+  exists <- doesFileExist absolutePath+  existingLog <-+    if exists+      then Log.parseLog <$> Text.IO.readFile absolutePath+      else pure (emptyLogFor targetPath)+  createDirectoryIfMissing True (FilePath.takeDirectory absolutePath)+  Text.IO.writeFile absolutePath (Log.serializeLog (Log.appendLogEntry entryDate entry existingLog))+  Text.IO.putStrLn ("Wrote " <> Text.pack targetPath <> " for " <> entryDate)++resolveLogTarget :: FilePath -> Maybe Text -> IO FilePath+resolveLogTarget _ Nothing =+  pure "log.md"+resolveLogTarget bundlePath (Just rawConceptId) = do+  parsed <- either (dieText . renderConceptIdError rawConceptId) pure (parseConceptId rawConceptId)+  concepts <- loadBundleOrExit bundlePath+  when (isNothing (findConcept parsed concepts)) $+    Text.IO.hPutStrLn stderr ("log: warning: concept not found: " <> rawConceptId)+  pure (logPathForConcept parsed)++logPathForConcept :: ConceptId -> FilePath+logPathForConcept conceptId =+  case FilePath.takeDirectory (conceptIdToFilePath conceptId) of+    "." -> "log.md"+    directory -> directory </> "log.md"++emptyLogFor :: FilePath -> Log.Log+emptyLogFor targetPath =+  Log.Log+    { Log.logTitle = defaultLogTitle targetPath,+      Log.logDays = []+    }++defaultLogTitle :: FilePath -> Text+defaultLogTitle targetPath =+  case FilePath.takeDirectory targetPath of+    "." -> "Bundle Update Log"+    directory -> Text.pack directory <> " Update Log"++todayDate :: IO Text+todayDate =+  Text.pack . formatTime defaultTimeLocale "%Y-%m-%d" . utctDay <$> getCurrentTime++runGitDriftCheck :: FilePath -> Text -> [LogFile] -> IO ()+runGitDriftCheck bundlePath ref logs = do+  result <-+    try+      ( readProcessWithExitCode+          "git"+          ["-C", bundlePath, "diff", "--name-only", "--relative", Text.unpack ref, "--", "."]+          ""+      )+  case result of+    Left (exception :: IOException) ->+      Text.IO.hPutStrLn stderr ("log: skipped git drift check: " <> Text.pack (show exception))+    Right (exitCode, output, errOutput) ->+      case exitCode of+        ExitSuccess ->+          mapM_ (Text.IO.hPutStrLn stderr . ("git: " <>) . renderGitDrift) (gitDriftForChangedPaths logs (Text.lines (Text.pack output)))+        ExitFailure _ ->+          Text.IO.hPutStrLn stderr ("log: skipped git drift check: " <> firstNonEmpty (Text.pack errOutput) (Text.pack output))++data GitDrift = GitDrift+  { driftConceptPath :: !FilePath,+    driftLogPath :: !(Maybe FilePath)+  }+  deriving stock (Generic, Eq, Show)++gitDriftForChangedPaths :: [LogFile] -> [Text] -> [GitDrift]+gitDriftForChangedPaths logs changed =+  [ GitDrift conceptPath nearestLog+  | conceptPath <- changedConcepts,+    let nearestLog = nearestEnclosingLogPath conceptPath allLogPaths,+    maybe True (`Set.notMember` changedSet) nearestLog+  ]+  where+    changedPaths = Text.unpack <$> filter (not . Text.null) changed+    changedSet = Set.fromList changedPaths+    changedConcepts =+      [ path+      | path <- changedPaths,+        FilePath.takeExtension path == ".md",+        not (isReservedMarkdownFile path)+      ]+    changedLogs =+      [ path+      | path <- changedPaths,+        FilePath.takeFileName path == "log.md"+      ]+    allLogPaths = List.nub (changedLogs <> (logSourcePath <$> logs))++renderGitDrift :: GitDrift -> Text+renderGitDrift GitDrift {driftConceptPath, driftLogPath} =+  Text.pack driftConceptPath+    <> " changed without "+    <> maybe "an enclosing log.md" (Text.pack . (<> " changing")) driftLogPath++firstNonEmpty :: Text -> Text -> Text+firstNonEmpty primary fallback+  | Text.null (Text.strip primary) = Text.strip fallback+  | otherwise = Text.strip primary++runGraph :: GraphOptions -> IO ()+runGraph GraphOptions {bundlePath} = do+  resolvedBundle <- resolveBundlePath bundlePath+  concepts <- loadBundleOrExit resolvedBundle+  LazyByteString.putStrLn (Aeson.encode (buildGraph concepts))++runShow :: ShowOptions -> IO ()+runShow ShowOptions {bundlePath, conceptIdText, profilePath, computationOnly, conceptOrder} = do+  case conceptIdText of+    Just rawIdentifier -> do+      resolvedBundle <- resolveBundlePath bundlePath+      concepts <- loadBundleOrExit resolvedBundle+      let render = if computationOnly then renderComputation resolvedBundle else renderConcept+      showConceptByIdentifier render profilePath concepts rawIdentifier+    Nothing -> do+      fzfConfig <- detectFzfConfig+      resolvedBundle <- resolveBundlePathWith fzfConfig bundlePath+      concepts <- loadBundleOrExit resolvedBundle+      let render = if computationOnly then renderComputation resolvedBundle else renderConcept+      selection <- selectConcept fzfConfig conceptOrder resolvedBundle concepts+      case selection of+        ConceptChosen concept -> render concept+        ConceptNoCandidates ->+          dieText ("No concepts found in " <> Text.pack resolvedBundle)+        ConceptSelectionCancelled -> exitWith (ExitFailure 130)+        ConceptSelectionUnavailable -> dieNoConceptPicker+        ConceptSelectionError message -> dieConceptFzf message++-- | Report the OKF v0.2 trust tier (§5.3), lifecycle status (§5.4), and+-- staleness (§5.5) for every concept in a bundle, one aligned line each.+--+-- Reads the clock exactly once and passes the day to 'staleness' for every+-- concept, so a run that straddles midnight cannot report two concepts against+-- different notions of "today". @okf-core@ never reads the clock itself; see+-- @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@.+--+-- Output is sorted by concept ID, which 'walkBundle' already guarantees, so the+-- report is stable and diffable in pipelines and CI.+runTrust :: TrustOptions -> IO ()+runTrust TrustOptions {bundlePath} = do+  resolvedBundle <- resolveBundlePath bundlePath+  concepts <- loadBundleOrExit resolvedBundle+  today <- utctDay <$> getCurrentTime+  let rows = trustRow today <$> concepts+      widthOf column = maximum (0 : map (Text.length . column) rows)+      (idWidth, tierWidth, statusWidth) =+        (widthOf (\(a, _, _, _) -> a), widthOf (\(_, b, _, _) -> b), widthOf (\(_, _, c, _) -> c))+  mapM_+    ( \(conceptId, tier, status, stale) ->+        Text.IO.putStrLn+          ( Text.intercalate+              "  "+              [ pad idWidth conceptId,+                pad tierWidth tier,+                pad statusWidth status,+                stale+              ]+          )+    )+    rows+  where+    pad width cell = cell <> Text.replicate (width - Text.length cell) " "+    trustRow today concept =+      ( renderConceptId (conceptIdOf concept),+        renderTrustTier (trustTier (conceptVerified concept)),+        renderStatus (conceptStatus concept),+        renderStaleness (staleness today (conceptStaleAfter concept))+      )++-- | List the OKF v0.2 @sources@ provenance recorded by each concept+-- (specification §5.1), with the credibility signals that frame it.+--+-- Concepts with no sources are skipped so the report shows only what has+-- provenance. Output is sorted by concept ID, which 'walkBundle' guarantees, so+-- the listing is stable and diffable in pipelines and CI.+--+-- Entries are printed in the order the document declares them and are never+-- sorted or ranked by @usage_count@. §5.1 warns that a count is a coarse signal+-- to be read "as liveness and trend, not as a score", and a ranked listing+-- would imply a precision the signal does not carry.+runSources :: SourcesOptions -> IO ()+runSources SourcesOptions {bundlePath} = do+  resolvedBundle <- resolveBundlePath bundlePath+  concepts <- loadBundleOrExit resolvedBundle+  let withSources = [concept | concept <- concepts, not (null (conceptSources concept))]+      labelWidth = maximum (0 : [Text.length (sourceLabel source) | concept <- withSources, source <- conceptSources concept])+  mapM_ (renderConceptSources labelWidth) withSources++renderConceptSources :: Int -> Concept -> IO ()+renderConceptSources labelWidth concept = do+  Text.IO.putStrLn (renderConceptId (conceptIdOf concept))+  mapM_ renderSource (conceptSources concept)+  where+    documentWindow = conceptUsageWindow concept+    renderSource source = do+      Text.IO.putStrLn ("  " <> pad labelWidth (sourceLabel source) <> "  " <> sourceResource source)+      -- Only signals actually present are named, so an entry with none prints+      -- no signals line rather than an empty one.+      case sourceSignals (effectiveUsageWindow documentWindow source) source of+        [] -> pure ()+        signals -> Text.IO.putStrLn ("  " <> pad labelWidth "" <> "  " <> Text.intercalate ", " signals)+    pad width cell = cell <> Text.replicate (width - Text.length cell) " "++-- | An entry's @id@, or a placeholder when it has none. §5.1 makes @id@+-- optional but recommends it when the body cites the source.+sourceLabel :: Source -> Text+sourceLabel Source {sourceId} = fromMaybe "(no id)" sourceId++-- | The credibility signals §5.1 defines, as display phrases, omitting each one+-- the entry does not carry. A @usage_count@ is always shown with the window+-- that frames it, since §5.1 makes a count without a window meaningless.+sourceSignals :: Maybe UsageWindow -> Source -> [Text]+sourceSignals window Source {sourceAuthor, sourceUsageCount, sourceLastModified} =+  concat+    [ ["author " <> renderActor author | Just author <- [sourceAuthor]],+      ["used " <> Text.pack (show count) <> " times" <> windowPhrase | Just count <- [sourceUsageCount]],+      ["modified " <> modified | Just modified <- [sourceLastModified]]+    ]+  where+    windowPhrase =+      case window of+        Just (UsageWindow (Just windowFrom) (Just windowTo)) -> " in " <> windowFrom <> ".." <> windowTo+        Just (UsageWindow (Just windowFrom) Nothing) -> " since " <> windowFrom+        Just (UsageWindow Nothing (Just windowTo)) -> " until " <> windowTo+        _ -> ""++-- | List the OKF v0.2 attested computations a bundle declares (specification+-- §10), one aligned line each.+--+-- This is specification §10.5 step 1 — \"Discover via @type: Attested+-- Computation@\" — asked of a whole bundle at once. Every other okf command+-- either takes one concept, reports every concept, or reports a different+-- family, so before this there was no way to ask a bundle what computations it+-- holds short of grepping @okf graph --json@.+--+-- Every column restates frontmatter and none says anything about a run. §10.5+-- marks the execute-and-attest workflow informative and puts the receipt and the+-- verdict outside the bundle entirely, so a column reading \"attests cleanly\"+-- would be a claim okf cannot make; see+-- @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@ for the general+-- principle that okf derives on read and stores nothing it was not told.+--+-- Selection is on the exact @type@ string and nothing else. A @Metric@ that+-- happens to carry a @runtime@ key is not an attested computation, and §4.1+-- keeps no taxonomy okf could consult to decide otherwise.+--+-- Absences print as a parenthesised phrase rather than as an empty cell, because+-- a blank column hides exactly what @okf validate --strict@ reports: @(no+-- runtime)@ is the §10.2-REQUIRED field missing, and @(2 computations)@ is+-- §10.3's exactly-one rule broken.+--+-- Output is sorted by concept ID, which 'walkBundle' already guarantees, so the+-- report is stable and diffable in pipelines and CI. A bundle with no attested+-- computations prints nothing and exits zero, as @okf sources@ does for a bundle+-- with no provenance: an empty report is not an error.+runComputations :: ComputationsOptions -> IO ()+runComputations ComputationsOptions {bundlePath} = do+  resolvedBundle <- resolveBundlePath bundlePath+  concepts <- loadBundleOrExit resolvedBundle+  mapM_ Text.IO.putStrLn (computationReport concepts)++-- | The lines @okf computations@ prints, as data. Pure and separate from+-- 'runComputations' so a test can assert the whole report rather than only the+-- accessors behind it; 'renderProfileDetail' is exported for the same reason.+--+-- Concepts arrive in 'walkBundle' order and keep it. Column widths are computed+-- over the selected rows only, so one unrelated long concept ID elsewhere in the+-- bundle cannot pad this report.+computationReport :: [Concept] -> [Text]+computationReport concepts =+  [ Text.intercalate+      "  "+      [ pad idWidth conceptId,+        pad runtimeWidth runtime,+        pad parameterWidth parameters,+        pad computationWidth computation,+        contract+      ]+  | (conceptId, runtime, parameters, computation, contract) <- rows+  ]+  where+    rows = computationRow <$> filter isAttestedComputation concepts+    widthOf column = maximum (0 : map (Text.length . column) rows)+    (idWidth, runtimeWidth, parameterWidth, computationWidth) =+      ( widthOf (\(a, _, _, _, _) -> a),+        widthOf (\(_, b, _, _, _) -> b),+        widthOf (\(_, _, c, _, _) -> c),+        widthOf (\(_, _, _, d, _) -> d)+      )+    pad width cell = cell <> Text.replicate (width - Text.length cell) " "+    isAttestedComputation concept = conceptType concept == attestedComputationType+    computationRow concept =+      ( renderConceptId (conceptIdOf concept),+        fromMaybe "(no runtime)" (conceptRuntime concept),+        case conceptParameters concept of+          [] -> "(no parameters)"+          parameters -> Text.intercalate ", " (renderParameter <$> parameters),+        computationLocation (conceptComputationSources concept),+        contractHalves concept+      )+    -- Where §10.3 says the computation lives. Naming the count rather than+    -- picking one of two is the honest answer: a concept offering both is what+    -- `okf validate --strict` reports, and `okf show --computation` refuses it+    -- for the same reason.+    computationLocation = \case+      [] -> "(no computation)"+      [ComputationFile rawPath] -> rawPath+      [ComputationInline _] -> "inline"+      sources -> "(" <> countPhrase (length sources) "computation" <> ")"+    -- Which of §10.2's two run-and-check halves the concept declares. Neither is+    -- REQUIRED, so all four combinations are legitimate and the phrase says which+    -- one this is rather than passing judgement on it.+    contractHalves concept =+      case (isJust (conceptExecutor concept), isJust (conceptAttester concept)) of+        (True, True) -> "executor + attester"+        (True, False) -> "executor"+        (False, True) -> "attester"+        (False, False) -> "(neither)"++-- | List the concepts a bundle holds, one aligned line each.+--+-- The whole-bundle reports that came before this one each answered a narrower+-- question — @okf trust@ always prints every concept and always the same four+-- columns, @okf sources@ only concepts with provenance, @okf computations@ only+-- concepts of one @type@ — so the simplest question anyone asks of a corpus,+-- \"which concepts are there\", had no answer short of a @jq@ expression over+-- @okf graph --json@.+--+-- Output is sorted by concept ID, which 'walkBundle' already guarantees and+-- 'filterConcepts' preserves, so the listing is stable and diffable in pipelines+-- and CI. A filter that matches nothing prints nothing and exits zero, as+-- @okf sources@ and @okf computations@ already do for a bundle with nothing to+-- report: an empty listing is an answer, not an error.+--+-- __A concept that omits a key never matches a filter on it__, even where OKF+-- supplies a default. @--where status=stable@ selects the concepts whose+-- frontmatter actually says @stable@ and not the ones that say nothing, even+-- though OKF v0.2 §5.4 reads an absent @status@ as @stable@. This command+-- restates frontmatter; a reading derived from absence is derived and not+-- stored, per @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@.+-- @okf trust@ is the command whose @status@ column does apply the default.+--+-- In text mode, @--show@ adds frontmatter columns to the aligned report. In+-- JSON mode, every selected row is instead the concept's complete stored+-- frontmatter object; @--show@ does not project or otherwise change it.+runConcepts :: ConceptsOptions -> IO ()+runConcepts+  ConceptsOptions+    { bundlePath,+      conceptTypes,+      fieldFilters,+      presentFields,+      absentFields,+      showFields,+      profilePath,+      json+    } = do+    resolvedBundle <- resolveBundlePath bundlePath+    traverse_ checkFiltersWithProfile profilePath+    concepts <- loadBundleOrExit resolvedBundle+    let selected = filterConcepts allFilters concepts+    if json+      then LazyByteString.putStrLn (Aeson.encode (conceptReportJson selected))+      else mapM_ Text.IO.putStrLn (conceptReport showFields selected)+    where+      -- @--type@ is sugar for a filter on the @type@ key rather than a separate+      -- mechanism, so there is one matching path to reason about and to test,+      -- and so that @--type Policy --type Metric@ means "either" for free.+      typeFilters = [FieldEquals (TopLevelField "type") wanted | wanted <- conceptTypes]+      -- Order does not affect the result -- 'filterConcepts' groups by selector+      -- and by question -- but is kept stable so that profile diagnostics report+      -- in a predictable order.+      allFilters =+        typeFilters+          <> fieldFilters+          <> (FieldPresent <$> presentFields)+          <> (FieldAbsent <$> absentFields)++      -- Before the bundle is walked, so a typo is reported instantly on a large+      -- bundle and the diagnostic is never mixed into a partial listing. The+      -- profile is used for nothing else: it does not validate the bundle, and+      -- this command never reports a bundle deviation. That is+      -- `okf validate --profile`'s job, and duplicating it here would give two+      -- commands that disagree about severity.+      checkFiltersWithProfile path = do+        spec <- loadProfileOrExit path+        compiled <- compileProfileOrExit (Text.pack path) spec+        case checkFiltersAgainstProfile compiled conceptTypes allFilters of+          [] -> pure ()+          profileErrors -> do+            -- Every error, not only the first, so one run fixes one command line.+            mapM_ (Text.IO.hPutStrLn stderr . renderFilterProfileError) profileErrors+            exitWith (ExitFailure 1)++-- | Why a profile says a filter can never select anything.+--+-- The message quotes the __filter__, @status=acepted@, and not the flag the user+-- typed. @--type Reqest@ desugars into a filter on the @type@ key before any+-- checking happens, so by the time an error exists there is no flag left to+-- quote and guessing one would sometimes name a flag the user did not use.+renderFilterProfileError :: FilterProfileError -> Text+renderFilterProfileError = \case+  FilterFieldNotDeclared selector ->+    "okf concepts: profile declares no frontmatter key named " <> renderFieldSelector selector+  FilterValueNotInVocabulary selector wanted vocabulary ->+    "okf concepts: no concept can match "+      <> renderFilter (FieldEquals selector wanted)+      <> "\n"+      <> renderFieldSelector selector+      <> " accepts: "+      <> Text.intercalate ", " vocabulary++-- | The lines @okf concepts@ prints, as data: concept ID, @type@, one column per+-- key requested with @--show@, and @title@ last. The @--show@ option affects+-- this text report only; JSON output always carries complete stored+-- frontmatter. Pure and separate from 'runConcepts' so a test can assert the+-- whole report rather than only the accessors behind it, as 'computationReport'+-- and 'renderProfileDetail' already are.+--+-- The three default columns are the ones+-- 'Okf.Cli.Fzf.Selector.conceptCandidates' shows in the interactive concept+-- picker, so the two listings agree.+--+-- @title@ comes last and is never padded, so a long title cannot push anything+-- off the right edge and a concept with no title simply ends the line. Column+-- widths are computed over the rows actually printed, so one unrelated long+-- concept ID elsewhere in the bundle cannot pad a filtered listing.+conceptReport :: [Text] -> [Concept] -> [Text]+conceptReport shown concepts =+  map renderRow rows+  where+    rows = conceptRow <$> concepts+    columnCount = 2 + length shown + 1+    widths =+      [ maximum (0 : map (Text.length . (!! column)) rows)+      | column <- [0 .. columnCount - 1]+      ]+    -- One padder per column, in order; the last column is left as it is.+    padders = replicate (columnCount - 1) padRight <> [\_ cell -> cell]+    padRight width cell = cell <> Text.replicate (max 0 (width - Text.length cell)) " "+    renderRow cells = Text.intercalate "  " (zipWith3 id padders widths cells)+    conceptRow concept =+      [renderConceptId (conceptIdOf concept), conceptType concept]+        <> [showCell (showSelector key) concept | key <- shown]+        <> [fromMaybe "" (conceptTitle concept)]+    -- A cell restates what the frontmatter says and nothing else. Absent, or+    -- holding something a table cell cannot show, both read @-@, matching the+    -- placeholder 'renderRegistryTable' already prints for an absent optional+    -- column. @--show generated@ naming a whole mapping is the second case:+    -- @--show generated.by@ is how to ask for what is inside it.+    showCell selector concept =+      case mapMaybe scalarText (conceptFieldValues selector concept) of+        [] -> "-"+        values -> Text.intercalate ", " values++-- | The rows @okf concepts --json@ emits.+--+-- Each row is the complete stored frontmatter object. File-derived identity,+-- source paths, Markdown bodies, derived readings, and a CLI-owned envelope are+-- deliberately absent. Filters choose the rows before this renderer runs, and+-- @--show@ is not an input because it controls text columns only.+conceptReportJson :: [Concept] -> Aeson.Value+conceptReportJson concepts =+  Aeson.toJSON (frontmatterValue <$> concepts)+  where+    frontmatterValue concept =+      case conceptDocument concept ^. #frontmatter of+        Frontmatter rawFields -> Aeson.Object rawFields++-- | Read a @--show@ key as a field selector. A key too deep to be one cannot+-- name a real frontmatter path either, so it is kept as a top-level key and+-- reports its column as absent rather than failing the run: @--show@ asks for+-- display, and nothing about the listing depends on it.+showSelector :: Text -> FieldSelector+showSelector key = either (const (TopLevelField key)) Prelude.id (parseFieldSelector key)++-- | Use an explicit bundle without consulting interactive availability, or+-- detect the picker configuration only when the positional was omitted.+resolveBundlePath :: Maybe FilePath -> IO FilePath+resolveBundlePath (Just path) = pure path+resolveBundlePath Nothing = do+  fzfConfig <- detectFzfConfig+  resolveBundlePathWith fzfConfig Nothing++-- | Resolve a bundle with an already-detected picker configuration. @show@+-- uses this when it may need the same configuration for its concept picker.+resolveBundlePathWith :: FzfConfig -> Maybe FilePath -> IO FilePath+resolveBundlePathWith _ (Just path) = pure path+resolveBundlePathWith fzfConfig Nothing = do+  selection <- selectBundle fzfConfig+  case selection of+    BundleChosen path -> pure path+    BundleNoCandidates roots ->+      dieText+        ( "No OKF bundles found under "+            <> Text.intercalate ", " (Text.pack <$> roots)+            <> ".\nA bundle directory holds an index.md or a Markdown file whose"+            <> " frontmatter declares a type."+            <> "\nPass a bundle path explicitly, or set "+            <> Text.pack bundleSearchRootsEnvVar+            <> " to a colon-separated list of directories to search."+        )+    BundleSelectionCancelled -> exitWith (ExitFailure 130)+    BundleSelectionUnavailable -> dieNoBundlePicker+    BundleSelectionError message -> dieBundleFzf message++-- | Use an explicit descriptor without probing interactivity, or detect the+-- picker only when no path was supplied.+resolveProfilePath :: Maybe FilePath -> IO FilePath+resolveProfilePath (Just path) = pure path+resolveProfilePath Nothing = do+  fzfConfig <- detectFzfConfig+  resolveProfilePathWith fzfConfig Nothing++-- | Resolve a descriptor with an already-detected picker configuration. This+-- is exported so tests can prove the explicit branch ignores availability.+resolveProfilePathWith :: FzfConfig -> Maybe FilePath -> IO FilePath+resolveProfilePathWith _ (Just path) = pure path+resolveProfilePathWith fzfConfig Nothing = do+  selection <- selectProfileDescriptor fzfConfig+  case selection of+    ProfileChosen path -> pure path+    ProfileNoCandidates roots ->+      dieText+        ( "No OKF profile descriptors found under "+            <> Text.intercalate ", " (Text.pack <$> roots)+            <> ".\nA descriptor is a .dhall file that decodes as an OKF profile."+            <> "\nPass --profile PATH explicitly, or set "+            <> Text.pack profileSearchRootsEnvVar+            <> " to a colon-separated list of directories to search."+        )+    ProfileSelectionCancelled -> exitWith (ExitFailure 130)+    ProfileSelectionUnavailable -> dieNoProfilePicker+    ProfileSelectionError message -> dieProfileFzf message++dieNoBundlePicker :: IO a+dieNoBundlePicker =+  dieTextWith+    (ExitFailure 2)+    ( "No BUNDLE given and interactive selection is unavailable."+        <> "\nInstall fzf (https://github.com/junegunn/fzf) and run okf from a terminal,"+        <> " or pass BUNDLE explicitly."+    )++dieBundleFzf :: Text -> IO a+dieBundleFzf message =+  dieTextWith (ExitFailure 2) ("Interactive bundle selection failed: " <> message)++dieNoProfilePicker :: IO a+dieNoProfilePicker =+  dieTextWith+    (ExitFailure 2)+    ( "No profile descriptor given and interactive selection is unavailable."+        <> "\nInstall fzf (https://github.com/junegunn/fzf) and run okf from a terminal,"+        <> " or pass --profile PATH explicitly."+    )++dieProfileFzf :: Text -> IO a+dieProfileFzf message =+  dieTextWith (ExitFailure 2) ("Interactive profile selection failed: " <> message)++dieNoConceptPicker :: IO a+dieNoConceptPicker =+  dieTextWith+    (ExitFailure 2)+    ( "okf show: no CONCEPT_ID given and interactive selection is unavailable."+        <> "\nInstall fzf (https://github.com/junegunn/fzf) and run okf from a terminal,"+        <> " or pass the argument: okf show [BUNDLE] [CONCEPT_ID]"+    )++dieConceptFzf :: Text -> IO a+dieConceptFzf message =+  dieTextWith (ExitFailure 2) ("okf show: interactive selection failed: " <> message)++-- | Resolve one identifier against a walked bundle: canonical concept path+-- first, then a profile-declared document ID. Unchanged from the previous+-- implementation of 'runShow', so the resolution order fixed by ADR 1 cannot+-- drift.+--+-- The renderer is a parameter rather than 'renderConcept' directly, so that+-- @okf show --computation@ resolves an identifier by exactly the same rules as+-- @okf show@.+showConceptByIdentifier :: (Concept -> IO ()) -> Maybe FilePath -> [Concept] -> Text -> IO ()+showConceptByIdentifier renderChosen profilePath concepts conceptIdText =+  case either (const Nothing) (`findConcept` concepts) (parseConceptId conceptIdText) of+    Just concept -> renderChosen concept+    Nothing ->+      case parseDocumentId conceptIdText of+        Nothing ->+          case parseConceptId conceptIdText of+            Left err -> dieText (renderConceptIdError conceptIdText err)+            Right _ -> dieText ("Concept not found: " <> conceptIdText)+        Just _ -> do+          searchField <-+            case profilePath of+              Nothing -> pure Nothing+              Just path -> do+                ProfileSpec {idField = profileIdField} <- loadProfileOrExit path+                maybe+                  (dieText ("Profile " <> Text.pack path <> " declares no idField"))+                  (pure . Just)+                  profileIdField+          case findConceptsByDocumentId searchField conceptIdText concepts of+            [] ->+              dieText+                ( "Concept not found: "+                    <> conceptIdText+                    <> " (no document carries that document ID)"+                )+            [concept] -> renderChosen concept+            matches ->+              dieText+                ( "Ambiguous document ID "+                    <> conceptIdText+                    <> ", found on: "+                    <> Text.intercalate ", " (renderConceptId . conceptIdOf <$> matches)+                    <> "\nRun okf validate --profile <descriptor> to see the duplicate as a violation."+                )++runId :: IdOptions -> IO ()+runId IdOptions {bundlePath, profilePath, idSub} = do+  resolvedBundle <- resolveBundlePath bundlePath+  spec <- loadProfileOrExit profilePath+  ProfileSpec {idField = profileIdField, types = typeRules} <- pure spec+  when (isNothing profileIdField) $+    dieText ("Profile " <> Text.pack profilePath <> " declares no idField")+  concepts <- loadBundleOrExit resolvedBundle   case idSub of     IdNext requestedPrefix -> do       let declaredPrefixes =
+ src/Okf/Cli/BundleDiscovery.hs view
@@ -0,0 +1,53 @@+-- | Non-interactive discovery of OKF bundles available to the CLI.+module Okf.Cli.BundleDiscovery+  ( BundleDiscovery (..),+    bundleSearchRootsEnvVar,+    parseBundleSearchRoots,+    bundleSearchRoots,+    discoverAvailableBundles,+  )+where++import Data.List qualified as List+import Data.Text qualified as Text+import Okf.Discovery (defaultDiscoveryOptions, discoverBundleRoots)+import System.Environment (lookupEnv)++-- | The effective search roots and the normalized candidates found below them.+data BundleDiscovery = BundleDiscovery+  { searchRoots :: ![FilePath],+    bundlePaths :: ![FilePath]+  }+  deriving stock (Show, Eq)++-- | Colon-separated search-root environment variable, in the style of @PATH@.+bundleSearchRootsEnvVar :: String+bundleSearchRootsEnvVar = "OKF_BUNDLE_ROOTS"++parseBundleSearchRoots :: String -> [FilePath]+parseBundleSearchRoots raw =+  [ Text.unpack trimmed+  | piece <- Text.splitOn ":" (Text.pack raw),+    let trimmed = Text.strip piece,+    not (Text.null trimmed)+  ]++-- | Where discovery starts. An absent or effectively empty override means the+-- current working directory.+bundleSearchRoots :: IO [FilePath]+bundleSearchRoots = do+  configured <- lookupEnv bundleSearchRootsEnvVar+  pure $ case configured of+    Nothing -> ["."]+    Just raw -> case parseBundleSearchRoots raw of+      [] -> ["."]+      roots -> roots++-- | Discover candidates once, sorting and deduplicating across every root.+discoverAvailableBundles :: IO BundleDiscovery+discoverAvailableBundles = do+  roots <- bundleSearchRoots+  discovered <-+    List.nub . List.sort . concat+      <$> traverse (discoverBundleRoots defaultDiscoveryOptions) roots+  pure BundleDiscovery {searchRoots = roots, bundlePaths = discovered}
src/Okf/Cli/Config.hs view
@@ -21,6 +21,7 @@     ConfigScope (..),     ConfigScopes (..),     defaultOkfConfig,+    defaultProfileSettings,     defaultAgentSettings,     emptyAgentFieldSettings,     agentSharedDefaults,@@ -116,8 +117,17 @@   deriving stock (Generic, Eq, Show)   deriving anyclass (FromDhall) --- | Profile-related settings: which registry @okf profile@ reads by default.+-- | Profile-related settings: which registries @okf profile@ reads by default. data ProfileSettings = ProfileSettings+  { registries :: ![Text]+  }+  deriving stock (Generic, Eq, Show)+  deriving anyclass (FromDhall)++-- | The @profiles@ block okf wrote before several registry sources were+-- supported. It survives only as a decode shape; one old reference maps to a+-- one-element current list.+data LegacyProfileSettings = LegacyProfileSettings   { registry :: !Text   }   deriving stock (Generic, Eq, Show)@@ -181,13 +191,24 @@   deriving stock (Generic, Eq, Show)   deriving anyclass (FromDhall) +-- | The current whole configuration record with the legacy singular+-- @profiles.registry@ spelling. This was the last shape okf wrote before+-- multi-source profile discovery.+data ConfigShapeWithLegacyProfiles = ConfigShapeWithLegacyProfiles+  { kit :: !KitSettings,+    agent :: !AgentSettings,+    profiles :: !LegacyProfileSettings+  }+  deriving stock (Generic, Eq, Show)+  deriving anyclass (FromDhall)+ -- | The configuration record as it stood before the @agent@ block replaced -- @assist@. Dhall decodes records strictly, so without this fallback changing -- the record would stop every existing config file from loading. data ConfigShapeWithoutAgent = ConfigShapeWithoutAgent   { kit :: !KitSettings,     assist :: !LegacyAssistSettings,-    profiles :: !ProfileSettings+    profiles :: !LegacyProfileSettings   }   deriving stock (Generic, Eq, Show)   deriving anyclass (FromDhall)@@ -238,7 +259,7 @@ defaultProfileSettings :: ProfileSettings defaultProfileSettings =   ProfileSettings-    { registry = defaultRegistryReference+    { registries = [defaultRegistryReference]     }  okfConfigEnvVar :: String@@ -334,7 +355,7 @@                 <> ": "                 <> err             )-        Right config -> Right (Just (agent config))+        Right OkfConfig {agent = loadedAgent} -> Right (Just loadedAgent)  -- | Decode one configuration file, trying each record shape okf has written, in -- order from newest to oldest, and filling the missing pieces from defaults.@@ -348,16 +369,20 @@ decodeConfigFile path = do   current <- tryDecode (Dhall.inputFile auto path)   case current of-    Right config -> pure (Right config)+    Right config -> pure (Right (normalizeProfileConfig config))     Left currentError -> do-      withoutAgent <- tryDecode (Dhall.inputFile auto path)-      case withoutAgent of-        Right shape -> pure (Right (fromShapeWithoutAgent shape))-        Left _withoutAgentError -> do-          v020 <- tryDecode (Dhall.inputFile auto path)-          pure $ case v020 of-            Right shape -> Right (fromShapeV020 shape)-            Left _v020Error -> Left currentError+      withLegacyProfiles <- tryDecode (Dhall.inputFile auto path)+      case withLegacyProfiles of+        Right shape -> pure (Right (fromShapeWithLegacyProfiles shape))+        Left _withLegacyProfilesError -> do+          withoutAgent <- tryDecode (Dhall.inputFile auto path)+          case withoutAgent of+            Right shape -> pure (Right (fromShapeWithoutAgent shape))+            Left _withoutAgentError -> do+              v020 <- tryDecode (Dhall.inputFile auto path)+              pure $ case v020 of+                Right shape -> Right (fromShapeV020 shape)+                Left _v020Error -> Left currentError   where     tryDecode :: IO a -> IO (Either Text a)     tryDecode action =@@ -365,9 +390,29 @@         `catch` \(exception :: SomeException) ->           pure (Left (Text.pack (show exception))) +normalizeProfileConfig :: OkfConfig -> OkfConfig+normalizeProfileConfig OkfConfig {kit, agent, profiles} =+  OkfConfig {kit, agent, profiles = normalizeProfileSettings profiles}++normalizeProfileSettings :: ProfileSettings -> ProfileSettings+normalizeProfileSettings ProfileSettings {registries} =+  ProfileSettings {registries = filter (not . Text.null) (map Text.strip registries)}++profileSettingsFromLegacy :: LegacyProfileSettings -> ProfileSettings+profileSettingsFromLegacy LegacyProfileSettings {registry} =+  normalizeProfileSettings (ProfileSettings {registries = [registry]})++fromShapeWithLegacyProfiles :: ConfigShapeWithLegacyProfiles -> OkfConfig+fromShapeWithLegacyProfiles ConfigShapeWithLegacyProfiles {kit, agent, profiles} =+  OkfConfig {kit, agent, profiles = profileSettingsFromLegacy profiles}+ fromShapeWithoutAgent :: ConfigShapeWithoutAgent -> OkfConfig fromShapeWithoutAgent ConfigShapeWithoutAgent {kit, assist, profiles} =-  OkfConfig {kit, agent = agentSettingsFromAssist assist, profiles}+  OkfConfig+    { kit,+      agent = agentSettingsFromAssist assist,+      profiles = profileSettingsFromLegacy profiles+    }  fromShapeV020 :: ConfigShapeV020 -> OkfConfig fromShapeV020 ConfigShapeV020 {kit, assist} =@@ -423,7 +468,7 @@   OkfConfig     { kit = KitSettings {repoUrl, providers},       agent = agentSettings@AgentSettings {assist = agentAssist},-      profiles = ProfileSettings {registry}+      profiles = ProfileSettings {registries}     } =     Text.unlines       ( [ "kit.repoUrl     = " <> repoUrl,@@ -431,9 +476,13 @@         ]           <> renderAgentFields "agent." (agentSharedDefaults agentSettings)           <> renderAgentFields "agent.assist." agentAssist-          <> ["profiles.registry = " <> registry]+          <> renderProfileRegistries registries       ) +renderProfileRegistries :: [Text] -> [Text]+renderProfileRegistries [] = ["profiles.registries = []"]+renderProfileRegistries registries = map ("profiles.registries = " <>) registries+ renderAgentFields :: Text -> AgentFieldSettings -> [Text] renderAgentFields keyPrefix AgentFieldSettings {provider, model, effort, systemPrompt} =   [ keyPrefix <> "provider = " <> maybe "(unset)" renderOkfProvider provider,@@ -479,7 +528,9 @@       "            }",       "        }",       "    , profiles =",-      "        { registry = \"" <> defaultRegistryReference <> "\"",+      "        { registries =",+      "            [ \"" <> defaultRegistryReference <> "\"",+      "            ]",       "        }",       "    }"     ]
src/Okf/Cli/Fzf/Selector.hs view
@@ -4,6 +4,7 @@   ( BundleSelection (..),     ConceptOrder (..),     ConceptSelection (..),+    ProfileSelection (..),     bundleSearchRootsEnvVar,     parseBundleSearchRoots,     parseConceptOrder,@@ -11,27 +12,39 @@     conceptCandidates,     conceptModificationTimes,     conceptPreviewCommand,+    profileCandidates,+    profilePreviewCommand,     orderConcepts,     renderConceptOrder,     selectBundle,     selectConcept,+    selectProfileDescriptor,     sortConceptsByModified,   ) where  import Control.Exception (IOException, try) import Data.List qualified as List-import Data.Maybe (fromMaybe)+import Data.Maybe (catMaybes, fromMaybe) import Data.Ord (Down (..)) import Data.Text (Text) import Data.Text qualified as Text import Data.Time.Clock (UTCTime) import Okf.Bundle (Concept, conceptIdOf, conceptSourcePath, conceptTitle, conceptType)+import Okf.Cli.BundleDiscovery+  ( BundleDiscovery (..),+    bundleSearchRoots,+    bundleSearchRootsEnvVar,+    discoverAvailableBundles,+    parseBundleSearchRoots,+  ) import Okf.Cli.Fzf+import Okf.Cli.ProfileDiscovery (ProfileDiscovery (..), discoverAvailableProfiles) import Okf.ConceptId (renderConceptId)-import Okf.Discovery (defaultDiscoveryOptions, discoverBundleRoots)+import Okf.Profile (ProfileSpec (..))+import Okf.Profile.Discovery (loadProfileDescriptorWithoutNetwork) import System.Directory (getModificationTime)-import System.Environment (getExecutablePath, lookupEnv)+import System.Environment (getExecutablePath) import System.FilePath ((</>))  -- | Outcome of asking the user to pick a bundle.@@ -55,6 +68,15 @@   | ConceptSelectionError !Text   deriving stock (Show, Eq) +-- | Outcome of asking the user to pick one discovered local descriptor.+data ProfileSelection+  = ProfileChosen !FilePath+  | ProfileNoCandidates ![FilePath]+  | ProfileSelectionCancelled+  | ProfileSelectionUnavailable+  | ProfileSelectionError !Text+  deriving stock (Show, Eq)+ -- | The order concepts appear in the interactive menu. data ConceptOrder   = -- | Most recently modified first. The default, because the concept a user@@ -79,38 +101,11 @@   ByModifiedTime -> "modified"   ByConceptId -> "id" --- | Colon-separated list of directories to search for bundles, in the style of--- @PATH@.-bundleSearchRootsEnvVar :: String-bundleSearchRootsEnvVar = "OKF_BUNDLE_ROOTS"--parseBundleSearchRoots :: String -> [FilePath]-parseBundleSearchRoots raw =-  [ Text.unpack trimmed-  | piece <- Text.splitOn ":" (Text.pack raw),-    let trimmed = Text.strip piece,-    not (Text.null trimmed)-  ]---- | Where to look for bundles: @OKF_BUNDLE_ROOTS@ when it names at least one--- directory, otherwise the current working directory.-bundleSearchRoots :: IO [FilePath]-bundleSearchRoots = do-  configured <- lookupEnv bundleSearchRootsEnvVar-  pure $ case configured of-    Nothing -> ["."]-    Just raw -> case parseBundleSearchRoots raw of-      [] -> ["."]-      roots -> roots- selectBundle :: FzfConfig -> IO BundleSelection selectBundle fzfConfig   | not (isFzfAvailable fzfConfig) = pure BundleSelectionUnavailable   | otherwise = do-      roots <- bundleSearchRoots-      discovered <--        List.nub . List.sort . concat-          <$> traverse (discoverBundleRoots defaultDiscoveryOptions) roots+      BundleDiscovery {searchRoots = roots, bundlePaths = discovered} <- discoverAvailableBundles       case discovered of         [] -> pure (BundleNoCandidates roots)         bundles -> do@@ -127,6 +122,36 @@             FzfCancelled -> BundleSelectionCancelled             FzfError message -> BundleSelectionError message +selectProfileDescriptor :: FzfConfig -> IO ProfileSelection+selectProfileDescriptor fzfConfig+  | not (isFzfAvailable fzfConfig) = pure ProfileSelectionUnavailable+  | otherwise = do+      ProfileDiscovery {searchRoots = roots, descriptorPaths} <- discoverAvailableProfiles+      loaded <-+        catMaybes+          <$> traverse+            ( \path -> do+                decoded <- loadProfileDescriptorWithoutNetwork path+                pure (fmap (\profile -> (path, profile)) (either (const Nothing) Just decoded))+            )+            descriptorPaths+      case loaded of+        [] -> pure (ProfileNoCandidates roots)+        profiles -> do+          executablePath <- getExecutablePath+          let opts =+                withPrompt "profile> "+                  <> withHeader "Select a local OKF profile descriptor"+                  <> withHeight "60%"+                  <> withNoSort+                  <> withPreview (profilePreviewCommand executablePath)+          result <- runFzf fzfConfig opts (profileCandidates profiles)+          pure $ case result of+            FzfSelected path -> ProfileChosen path+            FzfNoMatch -> ProfileNoCandidates roots+            FzfCancelled -> ProfileSelectionCancelled+            FzfError message -> ProfileSelectionError message+ selectConcept :: FzfConfig -> ConceptOrder -> FilePath -> [Concept] -> IO ConceptSelection selectConcept fzfConfig order bundlePath concepts   | not (isFzfAvailable fzfConfig) = pure ConceptSelectionUnavailable@@ -212,6 +237,28 @@     typeWidth = maximum (0 : map (Text.length . conceptType) concepts)     pad width value = value <> Text.replicate (max 0 (width - Text.length value)) " " +-- | One candidate per descriptor, displayed as path, profile name, and OKF+-- version. The path is display field 1 and therefore fzf preview field 2 after+-- the hidden numeric index added by 'renderCandidateLines'.+profileCandidates :: [(FilePath, ProfileSpec)] -> [Candidate FilePath]+profileCandidates profiles =+  [ Candidate+      { candidateDisplay =+          Text.intercalate+            "\t"+            [ pad pathWidth (Text.pack path),+              pad nameWidth name,+              okfVersion+            ],+        candidateValue = path+      }+  | (path, ProfileSpec {name, okfVersion}) <- profiles+  ]+  where+    pathWidth = maximum (0 : [Text.length (Text.pack path) | (path, _) <- profiles])+    nameWidth = maximum (0 : [Text.length name | (_, ProfileSpec {name}) <- profiles])+    pad width value = value <> Text.replicate (max 0 (width - Text.length value)) " "+ -- | The preview command fzf runs for the highlighted concept. @{2}@ is the -- concept ID: fzf extracts preview fields from the original input line, where -- field 1 is the hidden index, and it quotes the substitution itself.@@ -221,3 +268,10 @@     <> " show "     <> shellQuote (Text.pack bundlePath)     <> " {2}"++-- | Preview exactly one descriptor through the normal explicit registry path.+-- @--no-local@ prevents the preview from recursively appending discovery.+profilePreviewCommand :: FilePath -> Text+profilePreviewCommand executablePath =+  shellQuote (Text.pack executablePath)+    <> " profile show --no-local --registry {2}"
src/Okf/Cli/Help.hs view
@@ -45,6 +45,7 @@ helpTopics :: [HelpTopic] helpTopics =   [ HelpTopic "okf" "What the Open Knowledge Format is" okfTopicContent,+    HelpTopic "bundles" "Listing and discovering bundle paths" bundlesTopicContent,     HelpTopic "format" "Bundle layout, concept IDs, frontmatter, and links" formatTopicContent,     HelpTopic "validation" "How bundles are validated and referential integrity" validationTopicContent,     HelpTopic "profiles" "Checking a bundle against house conventions" profilesTopicContent,@@ -63,6 +64,9 @@  okfTopicContent :: Text okfTopicContent = $(embedStringFile "help/okf.md")++bundlesTopicContent :: Text+bundlesTopicContent = $(embedStringFile "help/bundles.md")  formatTopicContent :: Text formatTopicContent = $(embedStringFile "help/format.md")
+ src/Okf/Cli/ProfileDiscovery.hs view
@@ -0,0 +1,53 @@+-- | Non-interactive discovery of local profile descriptors available to the CLI.+module Okf.Cli.ProfileDiscovery+  ( ProfileDiscovery (..),+    profileSearchRootsEnvVar,+    parseProfileSearchRoots,+    profileSearchRoots,+    discoverAvailableProfiles,+  )+where++import Data.List qualified as List+import Okf.Cli.BundleDiscovery (parseBundleSearchRoots)+import Okf.Profile.Discovery (defaultProfileDiscoveryOptions, discoverProfileDescriptors)+import System.Environment (lookupEnv)++-- | The effective search roots and normalized descriptors found below them.+data ProfileDiscovery = ProfileDiscovery+  { searchRoots :: ![FilePath],+    descriptorPaths :: ![FilePath]+  }+  deriving stock (Show, Eq)++-- | Colon-separated filesystem search roots, in the style of @PATH@ and+-- @OKF_BUNDLE_ROOTS@. Registry references use a JSON array instead because+-- their own syntax contains colons.+profileSearchRootsEnvVar :: String+profileSearchRootsEnvVar = "OKF_PROFILE_ROOTS"++-- | The shared filesystem-root parser used by bundle discovery. This alias is+-- exported under the profile name so callers need not depend on that module's+-- terminology, while the parsing behavior cannot drift.+parseProfileSearchRoots :: String -> [FilePath]+parseProfileSearchRoots = parseBundleSearchRoots++-- | Where profile discovery starts. An absent or effectively empty override+-- means the current working directory.+profileSearchRoots :: IO [FilePath]+profileSearchRoots = do+  configured <- lookupEnv profileSearchRootsEnvVar+  pure $ case configured of+    Nothing -> ["."]+    Just raw -> case parseProfileSearchRoots raw of+      [] -> ["."]+      roots -> roots++-- | Discover candidates once, sorting and deduplicating across every root.+discoverAvailableProfiles :: IO ProfileDiscovery+discoverAvailableProfiles = do+  roots <- profileSearchRoots+  discovered <-+    List.nub . List.sort . concat+      <$> traverse (discoverProfileDescriptors defaultProfileDiscoveryOptions) roots+  pure ProfileDiscovery {searchRoots = roots, descriptorPaths = discovered}
test/Main.hs view
@@ -1,8 +1,9 @@ module Main (main) where -import Control.Exception (bracket)+import Control.Exception (bracket, try) import Control.Monad (unless) import Data.Aeson (Value (..), toJSON)+import Data.Aeson qualified as Aeson import Data.Foldable (traverse_) import Data.List qualified as List import Data.List.NonEmpty (NonEmpty (..))@@ -14,32 +15,44 @@ import Okf.Cli import Okf.Cli.Agent.Config (AgentCommandName (..), AgentConfigSource (..), AgentField (..), AgentOverrides (..), ResolvedAgent (..), ResolvedField (..), agentSourceLabel, noAgentOverrides, parseOkfEffort, parseOkfProvider, renderAgentResolution, resolveAgent) import Okf.Cli.Assist (AssistOptions (..), buildAgentCommand)-import Okf.Cli.Config (AgentFieldSettings (..), AgentSettings (..), ConfigSource (..), OkfConfig (..), OkfEffort (..), OkfProvider (..), agentSharedDefaults, defaultOkfConfig, exampleConfigText, findConfigSource, loadAgentScopes, loadOkfConfig, okfConfigEnvVar, projectConfigPath)-import Okf.Cli.Fzf (Candidate (..), FzfOpts (..), optsToArgs, parseSelectionIndex, renderCandidateLines, shellQuote, withAnsi, withHeight, withNoSort, withPrompt)-import Okf.Cli.Fzf.Selector (ConceptOrder (..), conceptCandidates, conceptPreviewCommand, orderConcepts, parseBundleSearchRoots)+import Okf.Cli.BundleDiscovery (BundleDiscovery (..), bundleSearchRootsEnvVar, discoverAvailableBundles)+import Okf.Cli.Config (AgentFieldSettings (..), AgentSettings (..), ConfigSource (..), OkfConfig (..), OkfEffort (..), OkfProvider (..), ProfileSettings (..), agentSharedDefaults, defaultOkfConfig, exampleConfigText, findConfigSource, loadAgentScopes, loadOkfConfig, okfConfigEnvVar, projectConfigPath)+import Okf.Cli.Fzf (Candidate (..), FzfConfig (..), FzfOpts (..), optsToArgs, parseSelectionIndex, renderCandidateLines, shellQuote, withAnsi, withHeight, withNoSort, withPrompt)+import Okf.Cli.Fzf.Selector (ConceptOrder (..), conceptCandidates, conceptPreviewCommand, orderConcepts, parseBundleSearchRoots, profileCandidates, profilePreviewCommand) import Okf.Cli.Help (HelpTopic (..), helpTopics)+import Okf.Cli.ProfileDiscovery (ProfileDiscovery (..), discoverAvailableProfiles, parseProfileSearchRoots, profileSearchRootsEnvVar) import Okf.ConceptId (ConceptId, parseConceptId, renderConceptId) import Okf.Document (Attester (..), Executor (..), Parameter (..), parseDocument) import Okf.Index (OkfVersion (..), VersionDeclaration (..), parseOkfVersion, readBundleVersion) import Okf.Profile (Cardinality (..), CompiledProfile, FieldCondition (..), FieldFormat (..), FieldPath (..), FieldPathSegment (..), FieldRule (..), FrontmatterRules (..), HandleReferenceRule (..), NestedFieldRule (..), NestedRules (..), PathReferenceRule (..), ProfileSpec (..), ProfileViolation (..), TypeRule (..), compileProfile, loadProfileFile, validateProfile, validateProfileVersion)-import Okf.Profile.Registry (RegistryEntry (..), defaultRegistryReference)+import Okf.Profile.Registry (ProfileSource (..), ProfileSourceLoadError (..), RegistryEntry (..), RegistryLoadError (..), RegistryRef (..), SourcedProfile (..), defaultRegistryReference) import Okf.Query (ConceptFilter (..), FieldSelector (..), filterConcepts) import Okf.Validation (ValidationProfile (..), validateBundle) import Options.Applicative-import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getCurrentDirectory, getTemporaryDirectory, listDirectory, removeDirectoryRecursive, setModificationTime, withCurrentDirectory)+import System.Directory (Permissions (..), createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getCurrentDirectory, getPermissions, getTemporaryDirectory, listDirectory, makeAbsolute, removeDirectoryRecursive, setModificationTime, setPermissions, withCurrentDirectory) import System.Environment (lookupEnv, setEnv, unsetEnv)-import System.Exit (exitFailure)-import System.FilePath ((</>))+import System.Exit (ExitCode (..), exitFailure)+import System.FilePath (normalise, (</>)) import System.IO.Temp (createTempDirectory)  main :: IO () main = do+  bundleDiscoveryListing <- testBundleDiscoveryListing+  profileDiscoveryListing <- testProfileDiscoveryListing+  profilePickerExitCodes <- testProfilePickerExitCodes+  effectiveProfileSources <- testEffectiveProfileSources   logAddWrites <- testLogAddWritesFile   configDefaults <- testConfigDefaults   configProjectPrecedence <- testConfigProjectPrecedence   configEnvPrecedence <- testConfigEnvPrecedence   configLegacyWithoutProfiles <- testConfigLegacyWithoutProfiles   configLegacyWithoutAgent <- testConfigLegacyWithoutAgent+  configLegacyProfilesWithAgent <- testConfigLegacyProfilesWithAgent+  configNormalizesRegistryList <- testConfigNormalizesRegistryList+  profileFlagSources <- testProfileFlagSources+  profileFlagPrecedence <- testProfileFlagSourcesBeatEnvironmentSources+  profileEnvironmentSources <- testProfileEnvironmentSources+  profileConfigOrigin <- testProfileConfigOrigin   agentScopesLoadsBothFiles <- testAgentScopesLoadsBothFiles   configInvalidDhall <- testConfigInvalidDhall   assistCommandBuilder <- testAssistCommandBuilder@@ -57,51 +70,130 @@   computationsReportsExample <- testComputationsReportsExampleBundle   conceptsReportsFixtures <- testConceptsReportsFixtureBundle   conceptsShowsFilteredColumns <- testConceptsShowsFilteredColumns+  conceptsReportJson <- testConceptReportJson   conceptsReportsExample <- testConceptsReportsExampleBundle   conceptsKeepsStatusDefaultOut <- testConceptsDoesNotApplyStatusDefault   profileDocStrictWithTimestamp <- testProfileDocumentationStrictWithTimestamp   conceptMenuOrdering <- testConceptMenuOrdering   nonAsciiDiagnostics <- testNonAsciiValuesSurviveDiagnostics   let results =-        [ parseSucceeds ["validate", "bundle"],+        [ parseBundlesMatches ["bundles"] (BundlesOptions False),+          parseBundlesMatches ["bundles", "--json"] (BundlesOptions True),+          parseProfilesMatches ["profiles"] (ProfilesOptions False),+          parseProfilesMatches ["profiles", "--json"] (ProfilesOptions True),+          observedIdPrefixes sampleHandleConcepts == ["ADR", "RFC"],+          bundleListJson [("a", []), ("b", ["ADR", "RFC"])]+            == Aeson.toJSON+              [ Aeson.object ["path" Aeson..= ("a" :: FilePath)],+                Aeson.object+                  [ "path" Aeson..= ("b" :: FilePath),+                    "idPrefixes" Aeson..= (["ADR", "RFC"] :: [Text.Text])+                  ]+              ],+          parseCommandMatches+            ["validate"]+            (Validate (ValidateOptions Nothing False Nothing False False False)),+          parseCommandMatches+            ["index"]+            (Index (IndexOptions Nothing False Nothing)),+          parseCommandMatches+            ["log"]+            (Log (LogOptions Nothing False Nothing LogPreview)),+          parseCommandMatches+            ["log", "add", "-m", "Root update"]+            ( Log+                ( LogOptions+                    Nothing+                    False+                    Nothing+                    (LogAdd (LogAddOptions Nothing "Update" "Root update" Nothing))+                )+            ),+          parseCommandMatches+            ["log", "add", "b", "-m", "Root update"]+            ( Log+                ( LogOptions+                    (Just "b")+                    False+                    Nothing+                    (LogAdd (LogAddOptions Nothing "Update" "Root update" Nothing))+                )+            ),+          parseCommandMatches+            ["graph"]+            (GraphCommand (GraphOptions Nothing False)),+          parseCommandMatches+            ["trust"]+            (Trust (TrustOptions Nothing)),+          parseCommandMatches+            ["sources"]+            (Sources (SourcesOptions Nothing)),+          parseCommandMatches+            ["computations"]+            (Computations (ComputationsOptions Nothing)),+          parseCommandMatches+            ["concepts"]+            (Concepts (ConceptsOptions Nothing [] [] [] [] [] Nothing False)),+          parseIdMatches+            ["id", "next", "ADR", "--profile", "p.dhall"]+            (IdOptions Nothing "p.dhall" (IdNext "ADR")),+          parseIdMatches+            ["id", "list", "--profile", "p.dhall"]+            (IdOptions Nothing "p.dhall" IdList),+          parseFails ["id", "next", "b", "ADR", "EXTRA", "--profile", "p.dhall"],+          parseSucceeds ["validate", "bundle"],           parseSucceeds ["validate", "bundle", "--strict"],           parseSucceeds ["validate", "bundle", "--profile", "p.dhall"],+          parseSucceeds ["validate", "bundle", "--pick-profile"],           parseSucceeds ["validate", "bundle", "--profile", "p.dhall", "--profile-enforce"],           parseSucceeds ["validate", "bundle", "--log-enforce"],           parseValidateMatches             ["validate", "b", "--profile", "p.dhall", "--profile-enforce"]             ValidateOptions-              { bundlePath = "b",+              { bundlePath = Just "b",                 strictMode = False,                 profilePath = Just "p.dhall",+                pickProfile = False,                 profileEnforce = True,                 logEnforce = False               },           parseValidateMatches             ["validate", "b"]             ValidateOptions-              { bundlePath = "b",+              { bundlePath = Just "b",                 strictMode = False,                 profilePath = Nothing,+                pickProfile = False,                 profileEnforce = False,                 logEnforce = False               },           parseValidateMatches             ["validate", "b", "--log-enforce"]             ValidateOptions-              { bundlePath = "b",+              { bundlePath = Just "b",                 strictMode = False,                 profilePath = Nothing,+                pickProfile = False,                 profileEnforce = False,                 logEnforce = True               },+          parseValidateMatches+            ["validate", "b", "--pick-profile"]+            ValidateOptions+              { bundlePath = Just "b",+                strictMode = False,+                profilePath = Nothing,+                pickProfile = True,+                profileEnforce = False,+                logEnforce = False+              },           parseSucceeds ["index", "bundle", "--write"],           parseSucceeds ["log", "bundle"],           parseSucceeds ["log", "bundle", "--check-stale"],           parseLogMatches             ["log", "b", "--check-stale", "--since", "HEAD~1"]             LogOptions-              { bundlePath = "b",+              { bundlePath = Just "b",                 checkStale = True,                 sinceRef = Just "HEAD~1",                 logSub = LogPreview@@ -109,7 +201,7 @@           parseLogMatches             ["log", "add", "b", "tables/users", "--kind", "Update", "-m", "Refreshed schema", "--date", "2026-06-23"]             LogOptions-              { bundlePath = "b",+              { bundlePath = Just "b",                 checkStale = False,                 sinceRef = Nothing,                 logSub =@@ -203,14 +295,14 @@           parseIdMatches             ["id", "next", "b", "ADR", "--profile", "p.dhall"]             IdOptions-              { bundlePath = "b",+              { bundlePath = Just "b",                 profilePath = "p.dhall",                 idSub = IdNext "ADR"               },           parseIdMatches             ["id", "list", "b", "--profile", "p.dhall"]             IdOptions-              { bundlePath = "b",+              { bundlePath = Just "b",                 profilePath = "p.dhall",                 idSub = IdList               },@@ -239,8 +331,10 @@           parseSucceeds ["help"],           parseSucceeds ["help", "okf"],           parseSucceeds ["help", "format"],+          parseSucceeds ["help", "bundles"],           parseSucceeds ["help", "concepts"],           any ((== "okf") . topicName) helpTopics,+          any ((== "bundles") . topicName) helpTopics,           any ((== "concepts") . topicName) helpTopics,           all (not . Text.null . topicContent) helpTopics,           optsToArgs (withPrompt "bundle> " <> withHeight "40%" <> withNoSort)@@ -259,10 +353,15 @@           parseBundleSearchRoots "/a:/b" == ["/a", "/b"],           parseBundleSearchRoots "" == [],           parseBundleSearchRoots " /a : : /b " == ["/a", "/b"],+          parseProfileSearchRoots " /a : : /b " == ["/a", "/b"],           conceptPreviewCommand "/usr/local/bin/okf" "my bundle"             == "'/usr/local/bin/okf' show 'my bundle' {2}",           conceptPreviewCommand "/opt/o'kf/okf" "b"             == "'/opt/o'\\''kf/okf' show 'b' {2}",+          profilePreviewCommand "/usr/local/bin/okf"+            == "'/usr/local/bin/okf' profile show --no-local --registry {2}",+          map candidateDisplay (profileCandidates [("p.dhall", samplePostgresqlProfile)])+            == ["p.dhall\tshinzui-postgresql\t0.1"],           sampleConceptDisplays             == ["tables/orders\tTable\tOrders", "x            \t     \t"],           parseSucceeds ["profile"],@@ -271,29 +370,48 @@           parseSucceeds ["profile", "list", "--registry", "./r.dhall"],           parseSucceeds ["profile", "show"],           parseSucceeds ["profile", "show", "postgresql"],-          parseProfileMatches ["profile"] (ProfileList (ProfileListOptions Nothing False)),+          parseProfileMatches ["profile"] (ProfileList (ProfileListOptions [] False False False)),           parseProfileMatches             ["profile", "list", "--registry", "r", "--json"]-            (ProfileList (ProfileListOptions (Just "r") True)),+            (ProfileList (ProfileListOptions ["r"] False True False)),           parseProfileMatches+            ["profile", "list", "--registry", "a", "--registry", "b"]+            (ProfileList (ProfileListOptions ["a", "b"] False False False)),+          parseProfileMatches+            ["profile", "list", "--no-local"]+            (ProfileList (ProfileListOptions [] True False False)),+          parseProfileMatches+            ["profile", "list", "--wide"]+            (ProfileList (ProfileListOptions [] False False True)),+          parseProfileMatches+            ["profile", "sources", "--registry", "r", "--no-local", "--json", "--check-latest"]+            (ProfileSources (ProfileSourcesOptions ["r"] True True True)),+          parseProfileMatches             ["profile", "show", "x", "--registry", "r", "--json"]-            (ProfileShow (ProfileShowOptions (Just "r") (Just "x") True)),+            (ProfileShow (ProfileShowOptions ["r"] (Just "x") False True)),           parseProfileMatches             ["profile", "show"]-            (ProfileShow (ProfileShowOptions Nothing Nothing False)),+            (ProfileShow (ProfileShowOptions [] Nothing False False)),+          parseProfileMatches+            ["profile", "show", "x", "--no-local"]+            (ProfileShow (ProfileShowOptions [] (Just "x") True False)),           parseSucceeds ["profile", "document"],           parseSucceeds ["profile", "document", "acme"],           parseSucceeds ["profile", "document", "--profile", "p.dhall"],+          parseSucceeds ["profile", "document", "--no-local", "acme"],           parseSucceeds ["profile", "document", "--out", "docs/p", "--write"],           parseSucceeds             ["profile", "document", "--registry", "./r.dhall", "acme", "--out", "d", "--write", "--timestamp", "2026-07-31T00:00:00Z"],+          parseSucceeds+            ["profile", "document", "--registry", "a", "--registry", "b", "acme"],           parseProfileMatches             ["profile", "document", "--registry", "./r.dhall", "acme", "--out", "d", "--write", "--timestamp", "2026-07-31T00:00:00Z"]             ( ProfileDocument                 ProfileDocumentOptions-                  { registryRef = Just "./r.dhall",+                  { registryRefs = ["./r.dhall"],                     export = Just "acme",                     profilePath = Nothing,+                    noLocal = False,                     outputPath = Just "d",                     write = True,                     timestamp = Just "2026-07-31T00:00:00Z",@@ -320,9 +438,10 @@             ]             ( ProfileDocument                 ProfileDocumentOptions-                  { registryRef = Nothing,+                  { registryRefs = [],                     export = Nothing,                     profilePath = Just "p.dhall",+                    noLocal = False,                     outputPath = Just "d",                     write = True,                     timestamp = Nothing,@@ -335,9 +454,10 @@             ["profile", "document"]             ( ProfileDocument                 ProfileDocumentOptions-                  { registryRef = Nothing,+                  { registryRefs = [],                     export = Nothing,                     profilePath = Nothing,+                    noLocal = False,                     outputPath = Nothing,                     write = False,                     timestamp = Nothing,@@ -350,9 +470,10 @@             ["profile", "document", "--profile", "p.dhall"]             ( ProfileDocument                 ProfileDocumentOptions-                  { registryRef = Nothing,+                  { registryRefs = [],                     export = Nothing,                     profilePath = Just "p.dhall",+                    noLocal = False,                     outputPath = Nothing,                     write = False,                     timestamp = Nothing,@@ -364,14 +485,13 @@           parseSucceeds ["trust", "bundle"],           parseSucceeds ["sources", "bundle"],           parseSucceeds ["computations", "bundle"],-          parseFails ["computations"],+          parseSucceeds ["computations"],           parseSucceeds ["concepts", "bundle"],-          -- The BUNDLE argument is required: this command never launches fzf.-          parseFails ["concepts"],+          parseSucceeds ["concepts"],           parseConceptsMatches             ["concepts", "b"]             ConceptsOptions-              { bundlePath = "b",+              { bundlePath = Just "b",                 conceptTypes = [],                 fieldFilters = [],                 presentFields = [],@@ -383,7 +503,7 @@           parseConceptsMatches             ["concepts", "b", "--json"]             ConceptsOptions-              { bundlePath = "b",+              { bundlePath = Just "b",                 conceptTypes = [],                 fieldFilters = [],                 presentFields = [],@@ -395,7 +515,7 @@           parseConceptsMatches             ["concepts", "b", "--type", "Policy", "--where", "status=accepted", "--show", "requestId"]             ConceptsOptions-              { bundlePath = "b",+              { bundlePath = Just "b",                 conceptTypes = ["Policy"],                 fieldFilters = [FieldEquals (TopLevelField "status") "accepted"],                 presentFields = [],@@ -407,7 +527,7 @@           parseConceptsMatches             ["concepts", "b", "--has", "completedAt", "--missing", "reviews.outcome"]             ConceptsOptions-              { bundlePath = "b",+              { bundlePath = Just "b",                 conceptTypes = [],                 fieldFilters = [],                 presentFields = [TopLevelField "completedAt"],@@ -421,13 +541,27 @@           parseFails ["concepts", "b", "--where", "status"],           parseFails ["concepts", "b", "--where", "a.b.c=x"],           parseFails ["concepts", "b", "--has", "a.b.c"],-          renderRegistryTable sampleRegistryEntries == sampleRegistryTable,+          renderRegistryTable CompactTable sampleRegistryEntries == sampleRegistryTable,+          renderRegistryTable WideTable sampleRegistryEntries == sampleRegistryWideTable,+          testRegistryTableCapsEveryTextField,+          testRegistryEnvironmentJson,+          testRegistryListJsonShape,+          testDescriptorSourceJsonShape,+          testProfileSourceResolutionRendering,+          testProfileSourcesJsonShape,+          testProfileReleaseTagParsing,+          testProfileDescriptorJsonShape,+          testAmbiguousSourcedProfile,           renderProfileDetail "nested.decisions" sampleDecisionsProfile == sampleProfileDetail,           renderProfileDetail "" samplePostgresqlProfile == sampleUndocumentedProfileDetail,           renderProfileDetail "" sampleNestedProfile == sampleNestedProfileDetail,           parseShowsInfo ["--version"],           parseFails ["hello"],           logAddWrites,+          bundleDiscoveryListing,+          profileDiscoveryListing,+          profilePickerExitCodes,+          effectiveProfileSources,           profileDocumentWrites,           profileDocumentDeclaresVersion,           profileDocMatchesExample,@@ -440,6 +574,7 @@           computationsReportsExample,           conceptsReportsFixtures,           conceptsShowsFilteredColumns,+          conceptsReportJson,           conceptsReportsExample,           conceptsKeepsStatusDefaultOut,           profileDocStrictWithTimestamp,@@ -450,6 +585,12 @@           configEnvPrecedence,           configLegacyWithoutProfiles,           configLegacyWithoutAgent,+          configLegacyProfilesWithAgent,+          configNormalizesRegistryList,+          profileFlagSources,+          profileFlagPrecedence,+          profileEnvironmentSources,+          profileConfigOrigin,           agentScopesLoadsBothFiles,           configInvalidDhall,           assistCommandBuilder,@@ -481,6 +622,14 @@     longConcept = buildConcept "tables/orders" "---\ntype: Table\ntitle: Orders\n---\n\n# Orders\n"     shortConcept = buildConcept "x" "---\ntype:\n---\n\n# x\n" +-- | Multiple fields and concepts can contribute handle families. Invalid+-- spellings and duplicate valid values do not affect the result.+sampleHandleConcepts :: [Concept]+sampleHandleConcepts =+  [ buildConcept "decisions/one" "---\ntype: Decision\ndocId: ADR-2\nalias: RFC-9\nlegacy: ADR-007\n---\n\n# One\n",+    buildConcept "decisions/two" "---\ntype: Decision\ndocId: ADR-1\n---\n\n# Two\n"+  ]+ -- | An in-memory concept from its identifier and document source, for tests -- that need a concept without a bundle on disk to walk. buildConcept :: Text.Text -> Text.Text -> Concept@@ -495,6 +644,24 @@     Success _ -> True     _ -> False +parseCommandMatches :: [String] -> Command -> Bool+parseCommandMatches args expected =+  case execParserPure defaultPrefs parserInfo args of+    Success (Options actual) -> actual == expected+    _ -> False++parseBundlesMatches :: [String] -> BundlesOptions -> Bool+parseBundlesMatches args expected =+  case execParserPure defaultPrefs parserInfo args of+    Success (Options (Bundles opts)) -> opts == expected+    _ -> False++parseProfilesMatches :: [String] -> ProfilesOptions -> Bool+parseProfilesMatches args expected =+  case execParserPure defaultPrefs parserInfo args of+    Success (Options (Profiles opts)) -> opts == expected+    _ -> False+ -- | Parse a @validate@ invocation and check it yields exactly the expected -- 'ValidateOptions' (so the new @--profile@/@--profile-enforce@ flags map to the -- right fields).@@ -571,24 +738,318 @@ expectedVocabularyLine =   "places/tokyo: frontmatter value at prefecture must be one of [東京都, 京都府], found: \"東京\"" --- | One root-level entry and one nested entry whose columns differ in width, so--- the padding in 'renderRegistryTable' is actually exercised, and the @(root)@--- and @-@ placeholders both appear.-sampleRegistryEntries :: [RegistryEntry]+-- | One root-level entry and one nested entry from differently sized source+-- labels, so every positional padder in 'renderRegistryTable' is exercised.+sampleRegistryEntries :: [SourcedProfile] sampleRegistryEntries =-  [ RegistryEntry {export = "", spec = samplePostgresqlProfile},-    RegistryEntry {export = "nested.decisions", spec = sampleDecisionsProfile}+  [ SourcedProfile+      { source = sampleCatalogueSource,+        entry = RegistryEntry {export = "", spec = samplePostgresqlProfile}+      },+    SourcedProfile+      { source = sampleHouseSource,+        entry = RegistryEntry {export = "nested.decisions", spec = sampleDecisionsProfile}+      }   ] --- | @DESCRIPTION@ is last and unpadded; the postgresql sample has none, so the--- @-@ placeholder appears there as well as in @ID FIELD@.+sampleCatalogueSource :: ProfileSource+sampleCatalogueSource = RegistrySource "catalogue" (RegistryExpression "catalogue")++sampleHouseSource :: ProfileSource+sampleHouseSource = RegistrySource "house" (RegistryExpression "house")++sampleResolvedSources :: [ResolvedProfileSource]+sampleResolvedSources =+  [ ResolvedProfileSource sampleCatalogueSource RegistryFlagOrigin,+    ResolvedProfileSource sampleHouseSource RegistryFlagOrigin+  ]++-- | Compact output pins the fixed budgets and the indented description line. sampleRegistryTable :: [Text.Text] sampleRegistryTable =-  [ "EXPORT            NAME                OKF  TYPES  ID FIELD  DESCRIPTION",-    "(root)            shinzui-postgresql  0.1      1  -         -",-    "nested.decisions  decisions           0.1      1  docId     How this team records architectural decisions."+  [ Text.concat+      [ "SOURCE",+        Text.replicate 10 " ",+        "EXPORT",+        Text.replicate 24 " ",+        "NAME",+        Text.replicate 26 " ",+        "OKF  TYPES  ID FIELD"+      ],+    Text.concat+      [ "catalogue",+        Text.replicate 7 " ",+        "(root)",+        Text.replicate 24 " ",+        "shinzui-postgresql",+        Text.replicate 12 " ",+        "0.1",+        Text.replicate 6 " ",+        "1  -"+      ],+    "  -",+    Text.concat+      [ "house",+        Text.replicate 11 " ",+        "nested.decisions",+        Text.replicate 14 " ",+        "decisions",+        Text.replicate 21 " ",+        "0.1",+        Text.replicate 6 " ",+        "1  docId"+      ],+    "  How this team records architectural decisions."   ] +sampleRegistryWideTable :: [Text.Text]+sampleRegistryWideTable =+  [ "SOURCE     EXPORT            NAME                OKF  TYPES  ID FIELD",+    "catalogue  (root)            shinzui-postgresql  0.1      1  -",+    "  -",+    "house      nested.decisions  decisions           0.1      1  docId",+    "  How this team records architectural decisions."+  ]++testRegistryTableCapsEveryTextField :: Bool+testRegistryTableCapsEveryTextField =+  case renderRegistryTable CompactTable [longEntry] of+    [_header, identity, description] ->+      maximum (map Text.length (renderRegistryTable CompactTable [longEntry])) <= 100+        && Text.last (Text.take 14 identity) == '…'+        && Text.last (Text.take 28 (Text.drop 16 identity)) == '…'+        && Text.last (Text.take 28 (Text.drop 46 identity)) == '…'+        && Text.last description == '…'+        && '\n' `notElem` Text.unpack description+        && longExport `Text.isInfixOf` Text.unlines (renderRegistryTable WideTable [longEntry])+        && longName `Text.isInfixOf` Text.unlines (renderRegistryTable WideTable [longEntry])+        && normalizedDescription `Text.isInfixOf` Text.unlines (renderRegistryTable WideTable [longEntry])+    _ -> False+  where+    longSourceText = Text.replicate 40 "source"+    longExport = Text.replicate 8 "nested.export."+    longName = Text.replicate 8 "long-profile-name-"+    longDescription = Text.replicate 10 "long description\nwith whitespace "+    normalizedDescription = Text.unwords (Text.words longDescription)+    longSource = RegistrySource longSourceText (RegistryExpression longSourceText)+    longSpec =+      sampleDecisionsProfile+        { name = longName,+          description = Just longDescription+        }+    longEntry = SourcedProfile longSource (RegistryEntry longExport longSpec)++testRegistryEnvironmentJson :: Bool+testRegistryEnvironmentJson =+  parseProfileRegistriesEnv+    "[\"https://example.test/package.dhall sha256:abc\",\"  \",\"./house\",\"./house\"]"+    == Right ["https://example.test/package.dhall sha256:abc", "./house"]+    && case parseProfileRegistriesEnv "[\"ok\", 1]" of+      Left _ -> True+      Right _ -> False++-- | JSON repeats the full source object on every profile and omits the legacy+-- singular key when more than one source is selected.+testRegistryListJsonShape :: Bool+testRegistryListJsonShape =+  registryListJson sampleResolvedSources sampleRegistryEntries+    == Aeson.object+      [ "sources"+          Aeson..= [sourceObject "catalogue", sourceObject "house"],+        "profiles"+          Aeson..= [ Aeson.object+                       [ "source" Aeson..= sourceObject "catalogue",+                         "export" Aeson..= ("" :: Text.Text),+                         "profile" Aeson..= samplePostgresqlProfile+                       ],+                     Aeson.object+                       [ "source" Aeson..= sourceObject "house",+                         "export" Aeson..= ("nested.decisions" :: Text.Text),+                         "profile" Aeson..= sampleDecisionsProfile+                       ]+                   ]+      ]+    && registryListJson (take 1 sampleResolvedSources) (take 1 sampleRegistryEntries)+      == Aeson.object+        [ "registry" Aeson..= ("catalogue" :: Text.Text),+          "sources" Aeson..= [sourceObject "catalogue"],+          "profiles"+            Aeson..= [ Aeson.object+                         [ "source" Aeson..= sourceObject "catalogue",+                           "export" Aeson..= ("" :: Text.Text),+                           "profile" Aeson..= samplePostgresqlProfile+                         ]+                     ]+        ]+  where+    sourceObject reference =+      Aeson.object+        [ "kind" Aeson..= ("registry" :: Text.Text),+          "label" Aeson..= (reference :: Text.Text),+          "reference" Aeson..= (reference :: Text.Text),+          "origin"+            Aeson..= Aeson.object+              [ "kind" Aeson..= ("flag" :: Text.Text),+                "name" Aeson..= ("--registry" :: Text.Text)+              ]+        ]++testDescriptorSourceJsonShape :: Bool+testDescriptorSourceJsonShape =+  registryListJson [resolved] [profile]+    == Aeson.object+      [ "sources" Aeson..= [sourceObject],+        "profiles"+          Aeson..= [ Aeson.object+                       [ "source" Aeson..= sourceObject,+                         "export" Aeson..= ("postgresql" :: Text.Text),+                         "profile" Aeson..= samplePostgresqlProfile+                       ]+                   ]+      ]+  where+    path = normalise "docs/profiles/postgresql.dhall"+    source = DescriptorSource path+    resolved = ResolvedProfileSource source (ProfileDiscoveryOrigin ["docs/profiles"])+    profile = SourcedProfile source (RegistryEntry "postgresql" samplePostgresqlProfile)+    sourceObject =+      Aeson.object+        [ "kind" Aeson..= ("descriptor" :: Text.Text),+          "label" Aeson..= ("local" :: Text.Text),+          "reference" Aeson..= path,+          "origin"+            Aeson..= Aeson.object+              [ "kind" Aeson..= ("discovery" :: Text.Text),+                "roots" Aeson..= (["docs/profiles"] :: [FilePath])+              ]+        ]++sampleProfileSourceResolutions :: [ProfileSourceResolution]+sampleProfileSourceResolutions =+  [ ProfileSourceResolution+      ( ResolvedProfileSource+          (RegistrySource defaultRegistryReference (RegistryExpression defaultRegistryReference))+          BuiltInRegistryOrigin+      )+      (ProfileSourceLoaded 10),+    ProfileSourceResolution+      (ResolvedProfileSource sampleHouseSource RegistriesEnvironmentOrigin)+      ( ProfileSourceFailed+          (RegistryProfileSourceLoadError sampleHouseSource RegistryImportFailure)+      )+  ]++testProfileSourceResolutionRendering :: Bool+testProfileSourceResolutionRendering =+  let rendered = renderProfileSourceResolution sampleProfileSourceResolutions FreshnessNotChecked+   in all+        (`Text.isInfixOf` rendered)+        [ "okf-profiles v0.10.0 (pinned)",+          "[built-in default]",+          "[env: OKF_PROFILE_REGISTRIES]",+          "failed (import-failure)",+          "Pinned catalogue: okf-profiles v0.10.0",+          "1. --registry flag (repeatable)",+          "lookup fails closed."+        ]++testProfileSourcesJsonShape :: Bool+testProfileSourcesJsonShape =+  profileSourcesJson sampleProfileSourceResolutions (FreshnessOutdated "v0.10.0" "v0.11.0")+    == Aeson.object+      [ "sources"+          Aeson..= [ Aeson.object+                       [ "kind" Aeson..= ("registry" :: Text.Text),+                         "label" Aeson..= ("okf-profiles" :: Text.Text),+                         "reference" Aeson..= defaultRegistryReference,+                         "origin" Aeson..= Aeson.object ["kind" Aeson..= ("built-in" :: Text.Text)],+                         "status" Aeson..= ("loaded" :: Text.Text),+                         "profileCount" Aeson..= (10 :: Int)+                       ],+                     Aeson.object+                       [ "kind" Aeson..= ("registry" :: Text.Text),+                         "label" Aeson..= ("house" :: Text.Text),+                         "reference" Aeson..= ("house" :: Text.Text),+                         "origin"+                           Aeson..= Aeson.object+                             [ "kind" Aeson..= ("environment" :: Text.Text),+                               "name" Aeson..= profileRegistriesEnvVar+                             ],+                         "status" Aeson..= ("failed" :: Text.Text),+                         "profileCount" Aeson..= (0 :: Int),+                         "error"+                           Aeson..= Aeson.object+                             [ "category" Aeson..= ("import-failure" :: Text.Text),+                               "message"+                                 Aeson..= ("one or more registry imports could not be resolved; check local paths and network access" :: Text.Text)+                             ]+                       ]+                   ],+        "pinnedVersion" Aeson..= Just ("v0.10.0" :: Text.Text),+        "freshness"+          Aeson..= Aeson.object+            [ "status" Aeson..= ("outdated" :: Text.Text),+              "pinnedVersion" Aeson..= ("v0.10.0" :: Text.Text),+              "latestVersion" Aeson..= ("v0.11.0" :: Text.Text),+              "refreshCommand" Aeson..= ("scripts/refresh-default-registry.sh v0.11.0" :: Text.Text)+            ],+        "precedence" Aeson..= profileSourcePrecedenceFixture+      ]++profileSourcePrecedenceFixture :: [Text.Text]+profileSourcePrecedenceFixture =+  [ "Precedence, highest first:",+    "  1. --registry flag (repeatable); the flag list replaces every other registry layer",+    "  2. OKF_PROFILE_REGISTRIES (JSON array)",+    "  3. OKF_PROFILE_REGISTRY (legacy single reference)",+    "  4. profiles.registries in the effective config file",+    "  5. built-in default when the decoded configuration has no profiles block",+    "Within a list, order is preserved and exact duplicates are dropped. Every source is",+    "enumerated; sources merge rather than replace. Local descriptors follow the winning",+    "registry list unless --no-local is passed. Survey commands report partial failure; named",+    "lookup fails closed."+  ]++testProfileReleaseTagParsing :: Bool+testProfileReleaseTagParsing =+  parseReleaseVersionTag "v0.10.0" == Just (ReleaseVersion 0 10 0)+    && parseReleaseVersionTag "v0.9.3" == Just (ReleaseVersion 0 9 3)+    && parseReleaseVersionTag "0.10.0" == Nothing+    && parseReleaseVersionTag "v0.10" == Nothing+    && pinnedRegistryTag defaultRegistryReference == Just "v0.10.0"+    && latestReleaseTag+      "aaa\trefs/tags/v0.9.3\nbbb\trefs/tags/not-a-version\nccc\trefs/tags/v0.10.0\n"+      == Just "v0.10.0"+    && latestReleaseTag "" == Nothing++testProfileDescriptorJsonShape :: Bool+testProfileDescriptorJsonShape =+  profileDescriptorJson "profile.dhall" sampleDecisionsProfile+    == Aeson.object+      [ "path" Aeson..= ("profile.dhall" :: FilePath),+        "name" Aeson..= ("decisions" :: Text.Text),+        "okfVersion" Aeson..= ("0.1" :: Text.Text),+        "description" Aeson..= ("How this team records architectural decisions." :: Text.Text)+      ]+    && profileDescriptorJson "postgresql.dhall" samplePostgresqlProfile+      == Aeson.object+        [ "path" Aeson..= ("postgresql.dhall" :: FilePath),+          "name" Aeson..= ("shinzui-postgresql" :: Text.Text),+          "okfVersion" Aeson..= ("0.1" :: Text.Text)+        ]++testAmbiguousSourcedProfile :: Bool+testAmbiguousSourcedProfile =+  case selectSourcedProfile collidingProfiles (Just "same") of+    Right _ -> False+    Left message ->+      all (`Text.isInfixOf` message) ["catalogue", "house", "exactly one intended --registry"]+  where+    collidingProfiles =+      [ SourcedProfile sampleCatalogueSource (RegistryEntry "same" samplePostgresqlProfile),+        SourcedProfile sampleHouseSource (RegistryEntry "same" sampleDecisionsProfile)+      ]+ -- | A profile with no descriptions anywhere — the shape an okf 0.2.x descriptor -- upgrades into. samplePostgresqlProfile :: ProfileSpec@@ -1049,9 +1510,10 @@   ProfileDocumentOptions exampleDocumentOptions descriptor destination stamp version =   ProfileDocumentOptions-    { registryRef = Nothing,+    { registryRefs = [],       export = Nothing,       profilePath = Just descriptor,+      noLocal = False,       outputPath = Just destination,       write = True,       timestamp = stamp,@@ -1340,6 +1802,56 @@       "requests/gamma  Improvement Request  completed  Gamma"     ] +-- | JSON rows are exactly the stored frontmatter objects in the input order.+-- The deliberately reverse-sorted concept IDs prove that the renderer does not+-- reorder or inject path-derived identity. Comparing 'Value' rather than bytes+-- ignores object-key order while pinning nested, list, scalar, and non-ASCII+-- values as well as the absence of the old @id@/@path@/@fields@ envelope.+testConceptReportJson :: IO Bool+testConceptReportJson = do+  let concepts =+        [ buildConcept+            "z/rich"+            "---\ntype: Signal\ntitle: 東京 signal\nproducerKey: custom\nlabels:\n  - alpha\n  - βeta\nnested:\n  region: 東京\n  active: true\nreviews:\n  - outcome: approved\n    reviewer: Renée\n---\n\nBody is not JSON.\n",+          buildConcept+            "a/minimal"+            "---\ntype: Note\ntitle: Second\ncount: 2\n---\n\nAnother body.\n"+        ]+      expected =+        Aeson.toJSON+          [ Aeson.object+              [ "type" Aeson..= ("Signal" :: Text.Text),+                "title" Aeson..= ("東京 signal" :: Text.Text),+                "producerKey" Aeson..= ("custom" :: Text.Text),+                "labels" Aeson..= (["alpha", "βeta"] :: [Text.Text]),+                "nested"+                  Aeson..= Aeson.object+                    [ "region" Aeson..= ("東京" :: Text.Text),+                      "active" Aeson..= True+                    ],+                "reviews"+                  Aeson..= [ Aeson.object+                               [ "outcome" Aeson..= ("approved" :: Text.Text),+                                 "reviewer" Aeson..= ("Renée" :: Text.Text)+                               ]+                           ]+              ],+            Aeson.object+              [ "type" Aeson..= ("Note" :: Text.Text),+                "title" Aeson..= ("Second" :: Text.Text),+                "count" Aeson..= (2 :: Int)+              ]+          ]+      actual = conceptReportJson concepts+  unless (actual == expected) $+    putStrLn+      ( "unexpected okf concepts JSON report:\nexpected: "+          <> show expected+          <> "\nactual:   "+          <> show actual+      )+  pure (actual == expected)+ -- | @okf concepts --type Policy@ over the shipped example. This is the -- transcript @docs\/user\/cli.md@ documents, pinned so the documentation cannot -- rot, exactly as 'testComputationsReportsExampleBundle' is.@@ -1469,9 +1981,10 @@       let destination = scratch </> "bundle"           options =             ProfileDocumentOptions-              { registryRef = Nothing,+              { registryRefs = [],                 export = Nothing,                 profilePath = Just descriptorPath,+                noLocal = False,                 outputPath = Just destination,                 write = True,                 timestamp = Nothing,@@ -1556,7 +2069,7 @@   runCommand     ( Log         LogOptions-          { bundlePath = root,+          { bundlePath = Just root,             checkStale = False,             sinceRef = Nothing,             logSub =@@ -1576,6 +2089,174 @@         && "* **Update**: Refreshed schema" `Text.isInfixOf` written     ) +-- | Discovery stays sorted and duplicate-free across repeated roots, an empty+-- search is successful, and JSON metadata failure leaves a path-only entry.+testBundleDiscoveryListing :: IO Bool+testBundleDiscoveryListing = do+  temporaryDirectory <- getTemporaryDirectory+  originalRoots <- lookupEnv bundleSearchRootsEnvVar+  bracket+    (createTempDirectory temporaryDirectory "okf-cli-bundle-discovery")+    ( \root -> do+        setMaybeEnv bundleSearchRootsEnvVar originalRoots+        removeDirectoryRecursive root+    )+    ( \root -> do+        setEnv bundleSearchRootsEnvVar root+        BundleDiscovery {bundlePaths = emptyPaths} <- discoverAvailableBundles++        let plain = root </> "a-plain"+            invalid = root </> "m-invalid"+            handled = root </> "z-handled"+        traverse_ (createDirectoryIfMissing True) [plain, invalid, handled]+        Text.IO.writeFile (plain </> "index.md") "# Plain\n"+        Text.IO.writeFile (invalid </> "index.md") "# Invalid\n"+        Text.IO.writeFile (invalid </> "broken.md") "---\ntype: Broken\n"+        Text.IO.writeFile+          (handled </> "decision.md")+          "---\ntype: Decision\ndocId: ADR-2\nrelatedId: BUG-3\nlegacy: ADR-007\n---\n\n# Decision\n"++        setEnv bundleSearchRootsEnvVar (root <> ":" <> root)+        BundleDiscovery {searchRoots, bundlePaths} <- discoverAvailableBundles+        entries <- traverse enrich bundlePaths+        let expectedPaths = [plain, invalid, handled]+            expectedJson =+              Aeson.toJSON+                [ Aeson.object ["path" Aeson..= plain],+                  Aeson.object ["path" Aeson..= invalid],+                  Aeson.object+                    [ "path" Aeson..= handled,+                      "idPrefixes" Aeson..= (["ADR", "BUG"] :: [Text.Text])+                    ]+                ]+            actualJson = bundleListJson entries+            passed =+              null emptyPaths+                && searchRoots == [root, root]+                && bundlePaths == expectedPaths+                && actualJson == expectedJson+        unless passed $+          putStrLn+            ( "bundle discovery listing mismatch:\npaths: "+                <> show bundlePaths+                <> "\nentries: "+                <> show actualJson+            )+        pure passed+    )+  where+    enrich path = do+      walked <- walkBundle path+      pure (path, either (const []) observedIdPrefixes walked)+    setMaybeEnv key = \case+      Nothing -> unsetEnv key+      Just envValue -> setEnv key envValue++testProfileDiscoveryListing :: IO Bool+testProfileDiscoveryListing =+  withRepositoryPath "profile discovery listing" ("docs" </> "profiles") $ \profileRoot -> do+    absoluteRoot <- makeAbsolute profileRoot+    originalRoots <- lookupEnv profileSearchRootsEnvVar+    bracket+      (setEnv profileSearchRootsEnvVar (absoluteRoot <> ":" <> absoluteRoot))+      (\() -> setMaybeEnv profileSearchRootsEnvVar originalRoots)+      ( \() -> do+          ProfileDiscovery {searchRoots, descriptorPaths} <- discoverAvailableProfiles+          let expectedPaths =+                [ absoluteRoot </> "okf-v0-2.dhall",+                  absoluteRoot </> "postgresql.dhall",+                  absoluteRoot </> "profile-documentation.dhall"+                ]+              passed =+                searchRoots == [absoluteRoot, absoluteRoot]+                  && descriptorPaths == expectedPaths+          unless passed $+            putStrLn+              ( "profile discovery listing mismatch:\nroots: "+                  <> show searchRoots+                  <> "\npaths: "+                  <> show descriptorPaths+              )+          pure passed+      )+  where+    setMaybeEnv key = \case+      Nothing -> unsetEnv key+      Just envValue -> setEnv key envValue++-- | The profile picker preserves ADR 2's exact 1 / 2 / 130 exits, while an+-- explicit path returns even with a deliberately unavailable configuration.+testProfilePickerExitCodes :: IO Bool+testProfilePickerExitCodes =+  withRepositoryPath "profile picker exits" ("docs" </> "profiles" </> "postgresql.dhall") $ \descriptor -> do+    absoluteDescriptor <- makeAbsolute descriptor+    temporaryDirectory <- getTemporaryDirectory+    originalRoots <- lookupEnv profileSearchRootsEnvVar+    bracket+      (createTempDirectory temporaryDirectory "okf-cli-profile-picker")+      ( \root -> do+          setMaybeEnv profileSearchRootsEnvVar originalRoots+          removeDirectoryRecursive root+      )+      ( \root -> do+          setEnv profileSearchRootsEnvVar root+          let unavailable = FzfConfig "fzf" False False False False+              pickerReady binary = FzfConfig binary True True False False+          explicit <- resolveProfilePathWith unavailable (Just absoluteDescriptor)+          unavailableExit <- try @ExitCode (resolveProfilePathWith unavailable Nothing)+          noCandidatesExit <- try @ExitCode (resolveProfilePathWith (pickerReady "/usr/bin/false") Nothing)++          let localDescriptor = root </> "postgresql.dhall"+              fakeFzf = root </> "fzf-cancel"+          Text.IO.writeFile localDescriptor (Text.pack absoluteDescriptor <> "\n")+          Text.IO.writeFile fakeFzf "#!/bin/sh\n/bin/cat >/dev/null\nexit 130\n"+          permissions <- getPermissions fakeFzf+          setPermissions fakeFzf permissions {executable = True}+          cancelledExit <- try @ExitCode (resolveProfilePathWith (pickerReady fakeFzf) Nothing)++          pure+            ( explicit == absoluteDescriptor+                && unavailableExit == Left (ExitFailure 2)+                && noCandidatesExit == Left (ExitFailure 1)+                && cancelledExit == Left (ExitFailure 130)+            )+      )+  where+    setMaybeEnv key = \case+      Nothing -> unsetEnv key+      Just envValue -> setEnv key envValue++testEffectiveProfileSources :: IO Bool+testEffectiveProfileSources =+  withRepositoryPath "effective profile sources" ("docs" </> "profiles") $ \profileRoot -> do+    absoluteRoot <- makeAbsolute profileRoot+    originalRoots <- lookupEnv profileSearchRootsEnvVar+    bracket+      (setEnv profileSearchRootsEnvVar absoluteRoot)+      (\() -> setMaybeEnv profileSearchRootsEnvVar originalRoots)+      ( \() -> do+          effective <- resolveEffectiveProfileSources ["registry"] False+          registryOnly <- resolveEffectiveProfileSources ["registry"] True+          pure $+            case (effective, registryOnly) of+              ( ResolvedProfileSource (RegistrySource "registry" _) RegistryFlagOrigin : discovered,+                [ResolvedProfileSource (RegistrySource "registry" _) RegistryFlagOrigin]+                ) ->+                  map (\ResolvedProfileSource {resolvedSource = source} -> source) discovered+                    == [ DescriptorSource (absoluteRoot </> "okf-v0-2.dhall"),+                         DescriptorSource (absoluteRoot </> "postgresql.dhall"),+                         DescriptorSource (absoluteRoot </> "profile-documentation.dhall")+                       ]+                    && all+                      (\ResolvedProfileSource {sourceOrigin} -> sourceOrigin == ProfileDiscoveryOrigin [absoluteRoot])+                      discovered+              _ -> False+      )+  where+    setMaybeEnv key = \case+      Nothing -> unsetEnv key+      Just envValue -> setEnv key envValue+ -- | Three concepts written in alphabetical order and stamped with mtimes that -- disagree with it, so the modification-time ordering can only come from the -- filesystem. A fourth concept has no file at all: it must land last under@@ -1683,6 +2364,101 @@     loaded <- loadOkfConfig     pure (loaded == Right (configWithMappedAssist (Just "legacy-model"), SourceProject projectPath)) +-- | The configuration shape immediately preceding multi-source discovery had+-- the current @agent@ block and the singular @profiles.registry@ field. It is+-- distinct from the older pre-agent shape and needs its own decode fallback.+testConfigLegacyProfilesWithAgent :: IO Bool+testConfigLegacyProfilesWithAgent =+  withIsolatedConfigEnv "okf-cli-config-legacy-profiles" $ do+    projectPath <- projectConfigPath+    Text.IO.writeFile projectPath (legacyAgentModelConfigText "legacy-agent-model")+    loaded <- loadOkfConfig+    pure $+      loaded+        == Right+          ( defaultOkfConfig {agent = agentSettingsWithSharedModel "legacy-agent-model"},+            SourceProject projectPath+          )++-- | Current list-valued settings strip surrounding whitespace and discard+-- blank elements without treating an explicit empty result as the default.+testConfigNormalizesRegistryList :: IO Bool+testConfigNormalizesRegistryList =+  withIsolatedConfigEnv "okf-cli-config-registry-list" $ do+    projectPath <- projectConfigPath+    Text.IO.writeFile projectPath currentConfigWithRegistryList+    loaded <- loadOkfConfig+    Text.IO.writeFile projectPath currentConfigWithEmptyRegistryList+    emptyLoaded <- loadOkfConfig+    pure $+      loaded+        == Right+          ( defaultOkfConfig+              { agent = agentSettingsWithSharedModel "",+                profiles = ProfileSettings {registries = ["./house.dhall"]}+              },+            SourceProject projectPath+          )+        && emptyLoaded+          == Right+            ( defaultOkfConfig+                { agent = agentSettingsWithSharedModel "",+                  profiles = ProfileSettings {registries = []}+                },+              SourceProject projectPath+            )++testProfileFlagSources :: IO Bool+testProfileFlagSources = do+  resolved <- resolveProfileSources ["first", "second", "first"]+  pure $+    case resolved of+      [ ResolvedProfileSource (RegistrySource "first" _) RegistryFlagOrigin,+        ResolvedProfileSource (RegistrySource "second" _) RegistryFlagOrigin+        ] -> True+      _ -> False++-- | The flag list is one authoritative layer. This assertion fails if the+-- resolver ever consults or concatenates the plural environment list ahead of+-- explicit flags.+testProfileFlagSourcesBeatEnvironmentSources :: IO Bool+testProfileFlagSourcesBeatEnvironmentSources =+  withIsolatedConfigEnv "okf-cli-profile-flag-precedence" $ do+    setEnv profileRegistriesEnvVar "[\"environment\"]"+    resolved <- resolveProfileSources ["flag"]+    pure $+      case resolved of+        [ResolvedProfileSource (RegistrySource "flag" _) RegistryFlagOrigin] -> True+        _ -> False++-- | The plural environment list wins over the legacy singular value and keeps+-- a hash-pinned URL intact as one reference.+testProfileEnvironmentSources :: IO Bool+testProfileEnvironmentSources =+  withIsolatedConfigEnv "okf-cli-profile-source-environment" $ do+    let pinned = "https://example.test/package.dhall sha256:abc"+    setEnv profileRegistriesEnvVar ("[\"" <> Text.unpack pinned <> "\",\"./house\"]")+    setEnv profileRegistryEnvVar "ignored"+    resolved <- resolveProfileSources []+    pure $+      case resolved of+        [ ResolvedProfileSource (RegistrySource first _) RegistriesEnvironmentOrigin,+          ResolvedProfileSource (RegistrySource second _) RegistriesEnvironmentOrigin+          ] -> first == pinned && second == "./house"+        _ -> False++testProfileConfigOrigin :: IO Bool+testProfileConfigOrigin =+  withIsolatedConfigEnv "okf-cli-profile-source-config" $ do+    projectPath <- projectConfigPath+    Text.IO.writeFile projectPath currentConfigWithRegistryList+    resolved <- resolveProfileSources []+    pure $+      case resolved of+        [ResolvedProfileSource (RegistrySource "./house.dhall" _) (ProfileConfigOrigin path)] ->+          path == projectPath+        _ -> False+ -- | The shape okf wrote after @profiles@ arrived and before @agent@ did. legacyWithProfilesConfigText :: Text.Text legacyWithProfilesConfigText =@@ -1771,11 +2547,48 @@       "            }",       "        }",       "    , profiles =",-      "        { registry = \"" <> defaultRegistryReference <> "\"",+      "        { registries = [ \"" <> defaultRegistryReference <> "\" ]",       "        }",       "    }"     ] +legacyAgentModelConfigText :: Text.Text -> Text.Text+legacyAgentModelConfigText modelName =+  Text.replace+    ("        { registries = [ \"" <> defaultRegistryReference <> "\" ]")+    ("        { registry = \"" <> defaultRegistryReference <> "\"")+    (agentModelConfigText modelName)++currentConfigWithRegistryList :: Text.Text+currentConfigWithRegistryList =+  Text.replace+    ("        { registries = [ \"" <> defaultRegistryReference <> "\" ]")+    "        { registries = [ \"   \" , \" ./house.dhall \" ]"+    (agentModelConfigText "")++currentConfigWithEmptyRegistryList :: Text.Text+currentConfigWithEmptyRegistryList =+  Text.replace+    ("        { registries = [ \"" <> defaultRegistryReference <> "\" ]")+    "        { registries = [] : List Text"+    (agentModelConfigText "")++agentSettingsWithSharedModel :: Text.Text -> AgentSettings+agentSettingsWithSharedModel modelName =+  AgentSettings+    { provider = Nothing,+      model = Just modelName,+      effort = Nothing,+      systemPrompt = Nothing,+      assist =+        AgentFieldSettings+          { provider = Nothing,+            model = Nothing,+            effort = Nothing,+            systemPrompt = Nothing+          }+    }+ testConfigInvalidDhall :: IO Bool testConfigInvalidDhall =   withIsolatedConfigEnv "okf-cli-config-invalid" $ do@@ -1909,16 +2722,22 @@   temporaryDirectory <- getTemporaryDirectory   originalCwd <- getCurrentDirectory   originalOkfConfig <- lookupEnv okfConfigEnvVar+  originalProfileRegistries <- lookupEnv profileRegistriesEnvVar+  originalProfileRegistry <- lookupEnv profileRegistryEnvVar   originalHome <- lookupEnv "HOME"   bracket     (createTempDirectory temporaryDirectory name)     ( \root -> do         setMaybeEnv okfConfigEnvVar originalOkfConfig+        setMaybeEnv profileRegistriesEnvVar originalProfileRegistries+        setMaybeEnv profileRegistryEnvVar originalProfileRegistry         setMaybeEnv "HOME" originalHome         withCurrentDirectory originalCwd (removeDirectoryRecursive root)     )     ( \root -> do         unsetEnv okfConfigEnvVar+        unsetEnv profileRegistriesEnvVar+        unsetEnv profileRegistryEnvVar         setEnv "HOME" root         withCurrentDirectory root runTest     )