packages feed

okf-cli 0.5.0.0 → 0.6.0.0

raw patch · 20 files changed

+2295/−202 lines, 20 filesdep +baikai-claudedep +baikai-openaidep ~baikaidep ~baikai-kitdep ~okf-corePVP ok

version bump matches the API change (PVP)

Dependencies added: baikai-claude, baikai-openai

Dependency ranges changed: baikai, baikai-kit, okf-core

API changes (from Hackage documentation)

- Okf.Cli.Assist: buildClaudeCommand :: OkfConfig -> [FilePath] -> AssistOptions -> [String]
- Okf.Cli.Config: AssistSettings :: !OkfProvider -> !Maybe Text -> !Maybe Text -> AssistSettings
- Okf.Cli.Config: data AssistSettings
- Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.AssistSettings
- Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.LegacyOkfConfig
- Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.AssistSettings
- Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.LegacyOkfConfig
- Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.AssistSettings
- Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.LegacyOkfConfig
- Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.AssistSettings
- Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.LegacyOkfConfig
+ Okf.Cli: Concepts :: ConceptsOptions -> Command
+ Okf.Cli: ConceptsOptions :: !FilePath -> ![Text] -> ![ConceptFilter] -> ![FieldSelector] -> ![FieldSelector] -> ![Text] -> !Maybe FilePath -> !Bool -> ConceptsOptions
+ Okf.Cli: ConfigAgent :: ConfigCommand
+ Okf.Cli: [absentFields] :: ConceptsOptions -> ![FieldSelector]
+ Okf.Cli: [conceptOrder] :: ShowOptions -> !ConceptOrder
+ Okf.Cli: [conceptTypes] :: ConceptsOptions -> ![Text]
+ Okf.Cli: [fieldFilters] :: ConceptsOptions -> ![ConceptFilter]
+ Okf.Cli: [presentFields] :: ConceptsOptions -> ![FieldSelector]
+ Okf.Cli: [showFields] :: ConceptsOptions -> ![Text]
+ Okf.Cli: conceptReport :: [Text] -> [Concept] -> [Text]
+ Okf.Cli: conceptReportJson :: [Text] -> [Concept] -> Value
+ Okf.Cli: data ConceptsOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ConceptsOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ConceptsOptions
+ Okf.Cli.Agent.Config: AgentCmdAssist :: AgentCommandName
+ Okf.Cli.Agent.Config: AgentOverrides :: !Maybe OkfProvider -> !Maybe Text -> !Maybe OkfEffort -> !Maybe Text -> AgentOverrides
+ Okf.Cli.Agent.Config: EffortField :: AgentField
+ Okf.Cli.Agent.Config: ModelField :: AgentField
+ Okf.Cli.Agent.Config: ProviderField :: AgentField
+ Okf.Cli.Agent.Config: ResolvedAgent :: !ResolvedField OkfProvider -> !Maybe (ResolvedField Text) -> !Maybe (ResolvedField OkfEffort) -> !Maybe (ResolvedField Text) -> ResolvedAgent
+ Okf.Cli.Agent.Config: ResolvedField :: a -> AgentConfigSource -> ResolvedField a
+ Okf.Cli.Agent.Config: SourceBuiltinDefault :: AgentConfigSource
+ Okf.Cli.Agent.Config: SourceCliFlag :: AgentConfigSource
+ Okf.Cli.Agent.Config: SourceEnvVar :: AgentConfigSource
+ Okf.Cli.Agent.Config: SourceGlobalCommand :: AgentConfigSource
+ Okf.Cli.Agent.Config: SourceGlobalDefault :: AgentConfigSource
+ Okf.Cli.Agent.Config: SourceLocalCommand :: AgentConfigSource
+ Okf.Cli.Agent.Config: SourceLocalDefault :: AgentConfigSource
+ Okf.Cli.Agent.Config: SystemPromptField :: AgentField
+ Okf.Cli.Agent.Config: [effort] :: ResolvedAgent -> !Maybe (ResolvedField OkfEffort)
+ Okf.Cli.Agent.Config: [model] :: ResolvedAgent -> !Maybe (ResolvedField Text)
+ Okf.Cli.Agent.Config: [provider] :: ResolvedAgent -> !ResolvedField OkfProvider
+ Okf.Cli.Agent.Config: [resolvedSource] :: ResolvedField a -> AgentConfigSource
+ Okf.Cli.Agent.Config: [resolvedValue] :: ResolvedField a -> a
+ Okf.Cli.Agent.Config: [systemPrompt] :: ResolvedAgent -> !Maybe (ResolvedField Text)
+ Okf.Cli.Agent.Config: agentCommandKey :: AgentCommandName -> AgentField -> Text
+ Okf.Cli.Agent.Config: agentCommandSegment :: AgentCommandName -> Text
+ Okf.Cli.Agent.Config: agentDefaultKey :: AgentField -> Text
+ Okf.Cli.Agent.Config: agentFieldEnvVar :: AgentField -> String
+ Okf.Cli.Agent.Config: agentFieldFlag :: AgentField -> Text
+ Okf.Cli.Agent.Config: agentFieldSegment :: AgentField -> Text
+ Okf.Cli.Agent.Config: agentSourceLabel :: AgentCommandName -> AgentField -> AgentConfigSource -> Text
+ Okf.Cli.Agent.Config: allAgentCommands :: [AgentCommandName]
+ Okf.Cli.Agent.Config: data AgentCommandName
+ Okf.Cli.Agent.Config: data AgentConfigSource
+ Okf.Cli.Agent.Config: data AgentField
+ Okf.Cli.Agent.Config: data AgentOverrides
+ Okf.Cli.Agent.Config: data ResolvedAgent
+ Okf.Cli.Agent.Config: data ResolvedField a
+ Okf.Cli.Agent.Config: instance GHC.Classes.Eq Okf.Cli.Agent.Config.AgentCommandName
+ Okf.Cli.Agent.Config: instance GHC.Classes.Eq Okf.Cli.Agent.Config.AgentConfigSource
+ Okf.Cli.Agent.Config: instance GHC.Classes.Eq Okf.Cli.Agent.Config.AgentField
+ Okf.Cli.Agent.Config: instance GHC.Classes.Eq Okf.Cli.Agent.Config.AgentOverrides
+ Okf.Cli.Agent.Config: instance GHC.Classes.Eq Okf.Cli.Agent.Config.ResolvedAgent
+ Okf.Cli.Agent.Config: instance GHC.Classes.Eq a => GHC.Classes.Eq (Okf.Cli.Agent.Config.ResolvedField a)
+ Okf.Cli.Agent.Config: instance GHC.Internal.Enum.Bounded Okf.Cli.Agent.Config.AgentCommandName
+ Okf.Cli.Agent.Config: instance GHC.Internal.Enum.Bounded Okf.Cli.Agent.Config.AgentField
+ Okf.Cli.Agent.Config: instance GHC.Internal.Enum.Enum Okf.Cli.Agent.Config.AgentCommandName
+ Okf.Cli.Agent.Config: instance GHC.Internal.Enum.Enum Okf.Cli.Agent.Config.AgentField
+ Okf.Cli.Agent.Config: instance GHC.Internal.Show.Show Okf.Cli.Agent.Config.AgentCommandName
+ Okf.Cli.Agent.Config: instance GHC.Internal.Show.Show Okf.Cli.Agent.Config.AgentConfigSource
+ Okf.Cli.Agent.Config: instance GHC.Internal.Show.Show Okf.Cli.Agent.Config.AgentField
+ Okf.Cli.Agent.Config: instance GHC.Internal.Show.Show Okf.Cli.Agent.Config.AgentOverrides
+ Okf.Cli.Agent.Config: instance GHC.Internal.Show.Show Okf.Cli.Agent.Config.ResolvedAgent
+ Okf.Cli.Agent.Config: instance GHC.Internal.Show.Show a => GHC.Internal.Show.Show (Okf.Cli.Agent.Config.ResolvedField a)
+ Okf.Cli.Agent.Config: noAgentOverrides :: AgentOverrides
+ Okf.Cli.Agent.Config: parseOkfEffort :: Text -> Either Text OkfEffort
+ Okf.Cli.Agent.Config: parseOkfProvider :: Text -> Either Text OkfProvider
+ Okf.Cli.Agent.Config: renderAgentResolution :: [(AgentCommandName, ResolvedAgent)] -> Text
+ Okf.Cli.Agent.Config: resolveAgent :: AgentCommandName -> AgentOverrides -> AgentOverrides -> Maybe AgentSettings -> Maybe AgentSettings -> ResolvedAgent
+ Okf.Cli.Agent.Config: thinkingLevelOf :: OkfEffort -> ThinkingLevel
+ Okf.Cli.Assist: [effortOverride] :: AssistOptions -> !Maybe OkfEffort
+ Okf.Cli.Assist: [providerOverride] :: AssistOptions -> !Maybe OkfProvider
+ Okf.Cli.Assist: [systemPromptOverride] :: AssistOptions -> !Maybe Text
+ Okf.Cli.Assist: assistAgentOverrides :: AssistOptions -> AgentOverrides
+ Okf.Cli.Assist: buildAgentCommand :: ResolvedAgent -> [FilePath] -> AssistOptions -> Either AgentRenderError (FilePath, [String])
+ Okf.Cli.Config: AgentFieldSettings :: !Maybe OkfProvider -> !Maybe Text -> !Maybe OkfEffort -> !Maybe Text -> AgentFieldSettings
+ Okf.Cli.Config: AgentSettings :: !Maybe OkfProvider -> !Maybe Text -> !Maybe OkfEffort -> !Maybe Text -> !AgentFieldSettings -> AgentSettings
+ Okf.Cli.Config: ConfigScopes :: !Maybe FilePath -> !Maybe FilePath -> ConfigScopes
+ Okf.Cli.Config: EffortHigh :: OkfEffort
+ Okf.Cli.Config: EffortLow :: OkfEffort
+ Okf.Cli.Config: EffortMax :: OkfEffort
+ Okf.Cli.Config: EffortMedium :: OkfEffort
+ Okf.Cli.Config: EffortMinimal :: OkfEffort
+ Okf.Cli.Config: EffortXHigh :: OkfEffort
+ Okf.Cli.Config: GlobalScope :: ConfigScope
+ Okf.Cli.Config: LocalScope :: ConfigScope
+ Okf.Cli.Config: [agent] :: OkfConfig -> !AgentSettings
+ Okf.Cli.Config: [effort] :: AgentSettings -> !Maybe OkfEffort
+ Okf.Cli.Config: [globalSource] :: ConfigScopes -> !Maybe FilePath
+ Okf.Cli.Config: [localSource] :: ConfigScopes -> !Maybe FilePath
+ Okf.Cli.Config: agentSharedDefaults :: AgentSettings -> AgentFieldSettings
+ Okf.Cli.Config: data AgentFieldSettings
+ Okf.Cli.Config: data AgentSettings
+ Okf.Cli.Config: data ConfigScope
+ Okf.Cli.Config: data ConfigScopes
+ Okf.Cli.Config: data OkfEffort
+ Okf.Cli.Config: defaultAgentSettings :: AgentSettings
+ Okf.Cli.Config: emptyAgentFieldSettings :: AgentFieldSettings
+ Okf.Cli.Config: findConfigScopes :: IO ConfigScopes
+ Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.AgentFieldSettings
+ Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.AgentSettings
+ Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.ConfigShapeV020
+ Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.ConfigShapeWithoutAgent
+ Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.LegacyAssistSettings
+ Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.OkfEffort
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.AgentFieldSettings
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.AgentSettings
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.ConfigScope
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.ConfigScopes
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.ConfigShapeV020
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.ConfigShapeWithoutAgent
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.LegacyAssistSettings
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.OkfEffort
+ Okf.Cli.Config: instance GHC.Internal.Enum.Bounded Okf.Cli.Config.OkfEffort
+ Okf.Cli.Config: instance GHC.Internal.Enum.Enum Okf.Cli.Config.OkfEffort
+ Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.AgentFieldSettings
+ Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.AgentSettings
+ Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.ConfigShapeV020
+ Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.ConfigShapeWithoutAgent
+ Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.LegacyAssistSettings
+ Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.OkfEffort
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.AgentFieldSettings
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.AgentSettings
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.ConfigScope
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.ConfigScopes
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.ConfigShapeV020
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.ConfigShapeWithoutAgent
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.LegacyAssistSettings
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.OkfEffort
+ Okf.Cli.Config: loadAgentScopes :: IO (Either Text (Maybe AgentSettings, Maybe AgentSettings))
+ Okf.Cli.Config: renderOkfEffort :: OkfEffort -> Text
+ Okf.Cli.Config: renderOkfProvider :: OkfProvider -> Text
+ Okf.Cli.Fzf.Selector: ByConceptId :: ConceptOrder
+ Okf.Cli.Fzf.Selector: ByModifiedTime :: ConceptOrder
+ Okf.Cli.Fzf.Selector: conceptModificationTimes :: FilePath -> [Concept] -> IO [(Concept, Maybe UTCTime)]
+ Okf.Cli.Fzf.Selector: data ConceptOrder
+ Okf.Cli.Fzf.Selector: instance GHC.Classes.Eq Okf.Cli.Fzf.Selector.ConceptOrder
+ Okf.Cli.Fzf.Selector: instance GHC.Internal.Show.Show Okf.Cli.Fzf.Selector.ConceptOrder
+ Okf.Cli.Fzf.Selector: orderConcepts :: ConceptOrder -> FilePath -> [Concept] -> IO [Concept]
+ Okf.Cli.Fzf.Selector: parseConceptOrder :: String -> Maybe ConceptOrder
+ Okf.Cli.Fzf.Selector: renderConceptOrder :: ConceptOrder -> Text
+ Okf.Cli.Fzf.Selector: sortConceptsByModified :: [(Concept, Maybe UTCTime)] -> [Concept]
- Okf.Cli: ShowOptions :: !Maybe FilePath -> !Maybe Text -> !Maybe FilePath -> !Bool -> ShowOptions
+ Okf.Cli: ShowOptions :: !Maybe FilePath -> !Maybe Text -> !Maybe FilePath -> !Bool -> !ConceptOrder -> ShowOptions
- Okf.Cli.Assist: AssistOptions :: !Text -> !Maybe Text -> !Bool -> AssistOptions
+ Okf.Cli.Assist: AssistOptions :: !Text -> !Maybe OkfProvider -> !Maybe Text -> !Maybe OkfEffort -> !Maybe Text -> !Bool -> AssistOptions
- Okf.Cli.Assist: handleAssistCommand :: OkfConfig -> AssistOptions -> IO ()
+ Okf.Cli.Assist: handleAssistCommand :: OkfConfig -> ResolvedAgent -> AssistOptions -> IO ()
- Okf.Cli.Config: OkfConfig :: !KitSettings -> !AssistSettings -> !ProfileSettings -> OkfConfig
+ Okf.Cli.Config: OkfConfig :: !KitSettings -> !AgentSettings -> !ProfileSettings -> OkfConfig
- Okf.Cli.Config: [assist] :: OkfConfig -> !AssistSettings
+ Okf.Cli.Config: [assist] :: AgentSettings -> !AgentFieldSettings
- Okf.Cli.Config: [model] :: AssistSettings -> !Maybe Text
+ Okf.Cli.Config: [model] :: AgentSettings -> !Maybe Text
- Okf.Cli.Config: [provider] :: AssistSettings -> !OkfProvider
+ Okf.Cli.Config: [provider] :: AgentSettings -> !Maybe OkfProvider
- Okf.Cli.Config: [systemPrompt] :: AssistSettings -> !Maybe Text
+ Okf.Cli.Config: [systemPrompt] :: AgentSettings -> !Maybe Text
- Okf.Cli.Fzf.Selector: selectConcept :: FzfConfig -> FilePath -> [Concept] -> IO ConceptSelection
+ Okf.Cli.Fzf.Selector: selectConcept :: FzfConfig -> ConceptOrder -> FilePath -> [Concept] -> IO ConceptSelection

Files

CHANGELOG.md view
@@ -7,6 +7,75 @@  ## [Unreleased] +## [0.6.0.0] - 2026-08-11++### Added++- `okf concepts BUNDLE` lists the concepts a bundle holds as one aligned row per+  concept — concept ID, `type`, `title` — in concept-ID order, and `--json`+  emits the same rows as an array with stable `id`, `path`, `type`, `title`, and+  `fields` keys.+- `okf concepts` filter flags, all repeatable: `--type TYPE`, `--where+  KEY=VALUE`, `--has KEY`, `--missing KEY`, and `--show KEY` for extra columns.+  Repeating a key means any-of and naming different keys means all-of, so+  `--type Policy --type Metric` lists both kinds and `--type Policy --where+  status=draft` lists the policies that are drafts. A key may name one level of+  nesting, as in `--where reviews.outcome=approved`.++  A concept that omits a key never matches a value filter on it, even where OKF+  supplies a default: `--where status=stable` selects the concepts whose+  frontmatter says so, not the ones that say nothing. `okf trust` is the command+  whose `status` column applies the default. A filter that matches nothing prints+  nothing and exits 0.+- `okf concepts --profile PROFILE` rejects a filter the profile says no concept+  could satisfy: a key it does not declare, a value outside a closed vocabulary,+  or a `--type` outside its declared type names. The diagnostic names what is+  accepted, prints on stderr, and exits 1 before the bundle is walked.++  Unlike `okf validate --profile`, this is a hard error rather than an advisory,+  because its subject is the command line the user just typed rather than the+  bundle. An advisory would print a warning and then the empty listing that+  caused the confusion in the first place.+- `okf help concepts`, the command-level guide for the above.+- An `agent` configuration block carrying `provider`, `model`, `effort`, and+  `systemPrompt` as shared defaults, plus an `agent.assist` sub-record whose keys+  win over them. `agent` settings are resolved across the project file and the+  global file together, so a project can override one field and inherit the rest;+  `kit` and `profiles` keep the first-found-wins rule.+- `okf assist --provider`, `--effort`, and `--system-prompt`, beside the existing+  `--model`, and the `OKF_AGENT_PROVIDER`, `OKF_AGENT_MODEL`, `OKF_AGENT_EFFORT`,+  and `OKF_AGENT_SYSTEM_PROMPT` environment variables. Flags beat environment+  variables, which beat both configuration scopes; within that, scope beats+  specificity across scopes and specificity beats scope within one.+- Reasoning effort as one neutral dial — `minimal`, `low`, `medium`, `high`,+  `xhigh`, `max` — rendered in whichever vocabulary the chosen agent accepts.+  Claude Code has no `minimal` level and receives `low`; Codex takes all six+  verbatim. Unset by default, so an unconfigured okf renders no effort flag.+- `okf config agent`, which prints the resolved value of every agent setting with+  the key or flag that supplied it, followed by the precedence order.++### Changed++- `okf assist` renders its command line through `baikai-claude` and+  `baikai-openai` rather than assembling it directly. The printed command line+  gained a `--` separator before the prompt.+- The `assist` configuration block is replaced by `agent.assist`. Files written+  for okf 0.2.0.0 and 0.5.0.0 still load; their `assist` values are read as+  `agent.assist.*`.+- Requires `okf-core ^>=0.6.0.0`, for `Okf.Query`, which `okf concepts` uses to+  filter. The `okf-core` half of this release is additive — nothing a consumer+  of the library calls today changed — but the two packages share one version,+  so the bound moves with `okf-cli`'s own breaking changes.+- Requires `baikai ^>=0.5.0` and `baikai-kit ^>=0.1.0.4`, and adds+  `baikai-claude ^>=0.5.0` and `baikai-openai ^>=0.5.0` for the vendor command+  lines. All four resolve from Hackage; the previous git pin on `baikai` is+  gone.++### Fixed++- `agent.provider = Codex` launches a real `codex` session. It previously exited+  2 with "the Codex provider is not yet supported".+ ## [0.5.0.0] - 2026-08-01  ### Added
help/agents.md view
@@ -14,12 +14,32 @@  ASSIST -  okf assist "PROMPT"               Launch an interactive Claude session with your-                                    installed okf skills on its path, starting from-                                    PROMPT.+  okf assist "PROMPT"               Launch an interactive agent session with your+                                    installed okf skills on its path, starting+                                    from PROMPT.   okf assist --print-command "PROMPT"-                                    Print the Claude command line without launching.+                                    Print the agent command line without+                                    launching it. +  Both Claude Code and Codex can be launched. The provider you choose is the CLI+  okf runs, so that CLI must be installed: 'claude' for Provider.Claude and+  'codex' for Provider.Codex. A missing binary exits 127 and names the one it+  looked for.++  Flags, each overriding whatever configuration resolved:++    --provider PROVIDER             claude or codex.+    --model MODEL                   Model to pass to the agent.+    --effort LEVEL                  minimal, low, medium, high, xhigh, or max.+    --system-prompt TEXT            Extra system prompt, appended to the agent's+                                    own rather than replacing it.++  The effort levels are the same six for both providers; okf renders whichever+  the chosen CLI actually accepts. Claude Code has no 'minimal' level, so+  '--effort minimal' reaches it as '--effort low', while Codex takes all six+  verbatim as '-c model_reasoning_effort=...'. Use --print-command to see what+  will run.+ CONFIGURATION    Settings live in okf-config.dhall (project) or ~/.config/okf/config.dhall@@ -30,9 +50,27 @@      kit.repoUrl     kit.providers-    assist.provider-    assist.model-    assist.systemPrompt+    agent.provider                  Shared defaults for every command that+    agent.model                     launches an agent.+    agent.effort+    agent.systemPrompt+    agent.assist.provider           Per-command settings for 'okf assist'.+    agent.assist.model              These win over the shared defaults above.+    agent.assist.effort+    agent.assist.systemPrompt++  Environment overrides, which beat both files and are deliberately not+  per-command:++    OKF_AGENT_PROVIDER+    OKF_AGENT_MODEL+    OKF_AGENT_EFFORT+    OKF_AGENT_SYSTEM_PROMPT++  Unlike kit.* and profiles.*, agent settings are read from the project file and+  the global file together, so a project can change one field and inherit the+  rest. Run 'okf config agent' to see what each setting resolved to and which+  key or flag supplied it. See 'okf help config' for the full precedence order.  PUBLISHING YOUR OWN SKILL 
help/computations.md view
@@ -86,3 +86,7 @@   okf help format       The contract keys and the exactly-one rule.   okf help validation   Which computation problems --strict reports.   okf help trust        Trust tiers, staleness, and recorded provenance.+  okf help concepts     The unrestricted listing, filterable by any key. Where+                        this report is fixed to one type and five columns,+                        `okf concepts --type 'Attested Computation' --show+                        runtime` asks the same question with columns you pick.
+ help/concepts.md view
@@ -0,0 +1,120 @@+LISTING AND FILTERING THE CONCEPTS IN A BUNDLE++Every non-reserved Markdown file in a bundle is a concept, and its frontmatter+says what it is. "okf concepts" is how you ask a bundle which concepts it holds+and which of them match what you care about. See "okf help format" for the+frontmatter contract; this topic is the tooling.++LISTING A BUNDLE'S CONCEPTS++  okf concepts BUNDLE++  One aligned row per concept, ordered by concept ID:++    policies/issue-invoice-on-order  Policy  Issue Invoice On Order+    policies/reserve-stock           Policy  Reserve Stock++  The three columns are the concept ID, the type, and the title -- the same+  three the interactive concept picker shows. Every one restates frontmatter and+  nothing else.++  Column widths are computed over the rows actually printed, so one long concept+  ID elsewhere in the bundle cannot pad a filtered listing.++FILTERING++  --type TYPE        Keep concepts whose type is exactly TYPE.+  --where KEY=VALUE  Keep concepts whose frontmatter KEY holds VALUE.+  --has KEY          Keep concepts that carry KEY at all.+  --missing KEY      Keep concepts that do not carry KEY.++  Every flag repeats. REPEATING A KEY MEANS "OR"; NAMING DIFFERENT KEYS MEANS+  "AND":++    okf concepts BUNDLE --type Policy --type Metric+    okf concepts BUNDLE --type Policy --where status=draft++  The first lists both kinds. The second lists the policies that are drafts.+  --type is sugar for --where type=..., so it obeys the same rule.++  A filter key is either a top-level key (status) or one level of nesting+  (reviews.outcome, generated.by). One level is the limit, because one level is+  what a profile can describe. A --where value is everything after the first+  '=', taken verbatim, so a value may contain '=' and its whitespace is kept.++  A filter on a list-valued key matches when ANY element matches, which is what+  you want when you ask for one tag on a concept that has three. The same holds+  one level down: --where reviews.outcome=approved selects a concept whose+  second review was approved even though its first asked for changes.++SHOWING MORE COLUMNS++  --show KEY adds a column between the type and the title, and repeats:++    okf concepts BUNDLE --where status=draft --show status+    policies/reserve-stock  Policy  draft  Reserve Stock++  Several values join with ", ". A key the concept does not carry, or one+  holding something a table cell cannot show, prints "-". --show generated+  naming a whole mapping is that second case; --show generated.by is how you ask+  for what is inside it.++TWO THINGS THAT SURPRISE PEOPLE++  A CONCEPT THAT OMITS A KEY NEVER MATCHES A VALUE FILTER ON IT, even where OKF+  supplies a default. --where status=stable selects the concepts whose+  frontmatter actually says stable, not the ones that say nothing, even though+  an absent status means stable. This command restates frontmatter; okf trust is+  the command whose status column applies the default.++  AN EMPTY RESULT IS NOT AN ERROR. A filter that matches nothing prints nothing+  and exits 0, as okf sources and okf computations already do.++CHECKING THE QUESTION AGAINST A PROFILE++  A filter is a guess about what the data says, and a wrong guess is invisible:+  --where status=acepted and --where status=withdrawn both print nothing, but+  one is a typo and the other is a true statement about the corpus. Pass+  --profile and okf will tell you which:++    okf concepts BUNDLE --profile PROFILE --where status=acepted+    okf concepts: no concept can match status=acepted+    status accepts: proposed, accepted, completed, rejected++    okf concepts BUNDLE --profile PROFILE --where statuz=accepted+    okf concepts: profile declares no frontmatter key named statuz++  Both print on stderr and exit 1, before the bundle is walked. This is a hard+  error rather than an advisory, unlike okf validate --profile, because the+  subject is the command line you just typed rather than the bundle. An advisory+  would print a warning and then the empty listing that caused the confusion.++  A --type value is checked against the profile's declared type names whenever+  the profile sets allowUnknownTypes = False, since that is how a profile spells+  its concept-type vocabulary. Everything else is checked against the allowed+  values of the rules that apply to the types in play: with --type, only those+  types; without it, every type the profile declares. A key the profile does not+  declare is reported unless OKF itself owns it.++  A profile that declares no vocabulary for a key cannot reject a value for it,+  and okf says nothing rather than guessing.++  The profile is used for nothing else here. okf concepts never reports a bundle+  deviation; that is okf validate --profile's job.++JSON OUTPUT++  okf concepts BUNDLE --show status --json++  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.++SEE ALSO++  okf help format         Bundle layout, concept IDs, and frontmatter.+  okf help profiles       Checking a bundle against house conventions.+  okf help trust          The report whose status column applies the default.+  okf help computations   The narrower report for attested computations.
help/config.md view
@@ -9,43 +9,99 @@   okf config show                   Print the effective configuration and where                                     it was loaded from.   okf config path                   Print only the selected configuration source.+  okf config agent                  Print the resolved agent settings and which+                                    key or flag supplied each one.   okf config init                   Write ./okf-config.dhall.   okf config init --global          Write ~/.config/okf/config.dhall.  SEARCH ORDER -  The first existing source wins:+  Two rules apply, because agent settings layer and nothing else does. +  For kit.* and profiles.*, the first existing source wins and the others are+  never read:+     1. OKF_CONFIG, when it points at an existing file     2. ./okf-config.dhall     3. ~/.config/okf/config.dhall     4. ~/.okf/config.dhall     5. built-in defaults +  For agent.*, the project file and the global file are both read, and each+  setting is resolved separately across them, highest first:++    1. --provider / --model / --effort / --system-prompt on okf assist+    2. OKF_AGENT_PROVIDER / OKF_AGENT_MODEL / OKF_AGENT_EFFORT /+       OKF_AGENT_SYSTEM_PROMPT+    3. local scope   agent.<command>.<field>+    4. local scope   agent.<field>+    5. global scope  agent.<command>.<field>+    6. global scope  agent.<field>+    7. built-in default++  The local scope is OKF_CONFIG when it points at an existing file, otherwise+  ./okf-config.dhall. The global scope is ~/.config/okf/config.dhall, otherwise+  ~/.okf/config.dhall. Note that setting OKF_CONFIG replaces the project file but+  does not suppress the global one: it names a file, not the only file.++  Scope beats specificity across scopes, and specificity beats scope within one.+  A local agent.model therefore wins over a global agent.assist.model, even+  though the global key is the more specific of the two. Run 'okf config agent'+  when in doubt; it prints the winning key for every setting.++  A value that is blank or only whitespace counts as unset and falls through to+  the next source.+ FIELDS    kit.repoUrl                       Git URL used by okf kit.   kit.providers                     Providers to install kit items for.-  assist.provider                   Provider launched by okf assist.-  assist.model                      Optional model override for assist.-  assist.systemPrompt               Optional extra system prompt for assist.+  agent.provider                    Agent CLI okf assist launches. Defaults to+                                    Provider.Claude.+  agent.model                       Model passed to the agent. Unset by default.+  agent.effort                      Reasoning effort. Unset by default, which+                                    renders no flag at all.+  agent.systemPrompt                Extra system prompt, appended to the agent's+                                    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. -  Providers are Dhall union values: Provider.Claude or Provider.Codex. Claude is-  currently the supported assist provider; Codex support is reserved for a later-  implementation.+  Providers are Dhall union values: Provider.Claude or Provider.Codex. Both are+  supported; the corresponding CLI ('claude' or 'codex') must be installed. +  Effort levels are Dhall union values: Effort.Minimal, Effort.Low,+  Effort.Medium, Effort.High, Effort.XHigh, or Effort.Max. okf renders whichever+  spelling the chosen provider accepts.++  A configuration file written for an earlier okf, with an 'assist' block instead+  of 'agent', still loads; its values are read as agent.assist.* .+ EXAMPLE    Run 'okf config init' to write this shape:      let Provider = < Claude | Codex >++    let Effort = < Minimal | Low | Medium | High | XHigh | Max >+     in  { kit =             { repoUrl = "https://github.com/shinzui/okf-kit.git"             , providers = [ Provider.Claude ]             }-        , assist =-            { provider = Provider.Claude+        , agent =+            { provider = None Provider             , model = None Text+            , effort = None Effort             , systemPrompt = None Text+            , assist =+                { provider = None Provider+                , model = None Text+                , effort = None Effort+                , systemPrompt = None Text+                }+            }+        , profiles =+            { registry = "..."             }         }
help/format.md view
@@ -140,6 +140,7 @@ SEE ALSO    okf help validation    How bundles are checked.+  okf help concepts      Listing and filtering concepts by their frontmatter.   okf help computations  Listing and printing attested computations.   okf help trust         Trust tiers, staleness, and recorded provenance.   okf help index         Generated index.md files, including the # Files section.
help/graph.md view
@@ -46,3 +46,7 @@    okf help format       Link spellings and what a dangling reference is.   okf help validation   Referential integrity checks.+  okf help concepts     Listing and filtering concepts. Reach for that before+                        this: a jq expression over these nodes used to be the+                        only way to ask which concepts match something, and+                        `okf concepts --where` now asks it directly.
help/ids.md view
@@ -78,3 +78,6 @@    okf help profiles     idField, idPrefix, and reference rules in full.   okf help format       Concept IDs, the canonical identity.+  okf help concepts     `okf concepts BUNDLE --show docId` shows each handle+                        beside its type and title, where `okf id list` gives+                        the handle and concept ID alone.
help/interactive.md view
@@ -43,6 +43,22 @@   and the pane on the right previews the highlighted concept exactly as   'okf show BUNDLE CONCEPT_ID' would print it. +  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.++  Pass --sort id to list concepts alphabetically by concept ID instead, which+  is the order every other okf command prints them in:++    okf show --sort id           alphabetical menu+    okf show --sort modified     most recently modified first (the default)++  --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.+   Typing filters on the concept ID, type, and title together.  KEYS
help/okf.md view
@@ -54,6 +54,7 @@   trust         Report each concept's trust tier, status, and staleness.   sources       List the provenance each concept records.   computations  List the attested computations a bundle declares.+  concepts      List the concepts a bundle holds, with optional filters.   id            Allocate and list profile-declared document IDs.   profile       List and inspect profiles published by a registry.   config        Show and manage okf configuration.@@ -81,9 +82,10 @@   okf help graph         The concept graph and what becomes an edge.   okf help trust         Trust tiers, staleness, and recorded provenance.   okf help computations  Listing and printing attested computations.+  okf help concepts      Listing and filtering the concepts in a bundle.   okf help ids           Profile-declared document IDs such as ADR-7.   okf help interactive   Picking a bundle and concept with fzf.-  okf help config        Config files, defaults, and agent settings.+  okf help config        Config files, precedence, and agent settings.   okf help kit           Installing and publishing agent skills and subagents.   okf help agents        Installing agent skills and launching assist. 
help/profiles.md view
@@ -38,6 +38,31 @@   OK: 3 concepts   profile: 1 advisory deviation(s) (use --profile-enforce to fail) +CHECKING A QUESTION RATHER THAN A BUNDLE++  okf concepts BUNDLE --profile PROFILE.dhall++  A profile can also check the command line you just typed. `okf concepts`+  takes --profile to reject a filter the profile says no concept could satisfy:+  a frontmatter key it does not declare, a value outside a closed vocabulary,+  or a --type outside its declared type names.++    okf concepts BUNDLE --profile PROFILE.dhall --where status=acepted+    okf concepts: no concept can match status=acepted+    status accepts: proposed, accepted, completed, rejected++  THIS IS A HARD ERROR, not an advisory, and there is no flag to make it one.+  That is not an inconsistency with the rules above: the subject is different.+  Validation judges a corpus okf did not write, so it defers to you. Here the+  subject is a question you asked one second ago, and a wrong guess is+  invisible -- --where status=acepted and --where status=withdrawn both print+  nothing, but one is a typo. An advisory would print a warning and then the+  empty listing that caused the confusion.++  The profile is used for nothing else there. `okf concepts` never reports a+  bundle deviation, so the two commands cannot disagree about severity for the+  same finding. See "okf help concepts".+ DOCUMENT IDS    A profile may name an idField such as "docId" and give selected type rules an@@ -394,6 +419,7 @@   okf help validation   Structural validation and referential integrity.   okf help ids          Document IDs: idField, idPrefix, and okf id.   okf help index        Declaring okf_version to satisfy requireBundleVersion.+  okf help concepts     Filtering a bundle, and how --profile checks a filter.    The full descriptor schema, and the upgrade steps above in detail, are   documented in docs/user/profiles.md, whose "Generating profile
help/trust.md view
@@ -35,6 +35,13 @@   status shows stable for a concept that declares none, because an absent   status means stable. The other values are draft, deprecated, and superseded. +  This is the one column okf derives rather than restates, so it is the one+  place this report and `okf concepts` disagree on purpose. `okf concepts+  --where status=stable` selects only the concepts whose frontmatter actually+  says stable; the ones that say nothing appear here as stable and there not at+  all. Ask this command for the derived reading and that one for what is+  written down.+   The staleness column reads "ok" both for a concept with no stale_after and   for one whose deadline has not arrived -- okf does not claim a concept is   fresh, only that nothing says otherwise. A passed deadline prints@@ -87,3 +94,5 @@   okf help format       The generated, verified, status, and sources families.   okf help validation   Which provenance problems --strict reports.   okf help log          Log staleness, which is a different check.+  okf help concepts     Listing concepts by what their frontmatter says,+                        without the derived status default this report applies.
okf-cli.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.4 name:               okf-cli-version:            0.5.0.0+version:            0.6.0.0 synopsis:           Command-line interface for Open Knowledge Format bundles description:   okf-cli provides the @okf@ executable for working with Open Knowledge Format@@ -40,6 +40,7 @@   hs-source-dirs:  src   exposed-modules:     Okf.Cli+    Okf.Cli.Agent.Config     Okf.Cli.Assist     Okf.Cli.Completions     Okf.Cli.Config@@ -54,8 +55,10 @@   autogen-modules: Paths_okf_cli   build-depends:     , aeson                 >=2.2      && <2.4-    , baikai                ^>=0.3.0-    , baikai-kit            ^>=0.1.0.1+    , baikai                ^>=0.5.0+    , baikai-claude         ^>=0.5.0+    , baikai-kit            ^>=0.1.0.4+    , baikai-openai         ^>=0.5.0     , base                  >=4.20     && <5     , bytestring            >=0.11     && <0.13     , containers            >=0.6      && <0.8@@ -66,7 +69,7 @@     , generic-lens          >=2.2      && <2.4     , githash               ^>=0.1     , lens                  ^>=5.3-    , okf-core              ^>=0.5.0.0+    , okf-core              ^>=0.6.0.0     , optparse-applicative  >=0.18     && <0.20     , process               >=1.6      && <1.7     , text                  ^>=2.1@@ -82,10 +85,11 @@     , directory     , filepath     , okf-cli-    , okf-core              ^>=0.5.0.0+    , okf-core              ^>=0.6.0.0     , optparse-applicative  >=0.18     , temporary     , text                  ^>=2.1+    , time                  >=1.12     && <1.15  executable okf   import:         common-options
src/Okf/Cli.hs view
@@ -2,6 +2,7 @@ module Okf.Cli   ( Command (..),     ComputationsOptions (..),+    ConceptsOptions (..),     GraphOptions (..),     IdOptions (..),     IdSub (..),@@ -18,6 +19,8 @@     ShowOptions (..),     ValidateOptions (..),     computationReport,+    conceptReport,+    conceptReportJson,     parserInfo,     profileRegistryEnvVar,     renderProfileDetail,@@ -37,20 +40,37 @@ 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.IO qualified as Text.IO import Data.Time (defaultTimeLocale, formatTime, getCurrentTime, utctDay) import Okf.Actor (parseActor, renderActor) import Okf.Bundle-import Okf.Cli.Assist (AssistOptions, assistOptionsParser, handleAssistCommand)+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,   )@@ -72,6 +92,7 @@     attestedComputationType,     body,     effectiveUsageWindow,+    frontmatterLookup,     renderStatus,   ) import Okf.Graph (buildGraph)@@ -128,6 +149,20 @@     resolveRegistryRef,     rootExportLabel,   )+import Okf.Query+  ( ConceptFilter (..),+    FieldSelector (..),+    FilterProfileError (..),+    checkFiltersAgainstProfile,+    conceptFieldValues,+    filterConcepts,+    parseFieldEquals,+    parseFieldSelector,+    renderFieldSelector,+    renderFilter,+    renderFilterParseError,+    scalarText,+  ) import Okf.Trust   ( Staleness (..),     latestVerification,@@ -155,6 +190,7 @@   | Trust TrustOptions   | Sources SourcesOptions   | Computations ComputationsOptions+  | Concepts ConceptsOptions   | Id IdOptions   | Config ConfigCommand   | Profile ProfileCommand@@ -211,7 +247,10 @@   { bundlePath :: !(Maybe FilePath),     conceptIdText :: !(Maybe Text),     profilePath :: !(Maybe FilePath),-    computationOnly :: !Bool+    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) @@ -230,6 +269,18 @@   }   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,@@ -246,6 +297,7 @@   = ConfigShow   | ConfigPath   | ConfigInit !Bool+  | ConfigAgent   deriving stock (Show, Eq)  data ProfileCommand@@ -319,6 +371,7 @@         <> 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"))@@ -461,6 +514,14 @@       ( 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 =@@ -502,6 +563,12 @@               (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 @@ -634,6 +701,58 @@ 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@@ -644,6 +763,7 @@   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@@ -652,7 +772,8 @@     handleKitCommand config kitCommand   Assist assistOptions -> do     config <- loadConfigOrDie-    handleAssistCommand config assistOptions+    resolved <- resolveAgentOrDie AgentCmdAssist (assistAgentOverrides assistOptions)+    handleAssistCommand config resolved assistOptions   Completions shell -> handleCompletions shell   Help helpCommand -> handleHelpCommand helpCommand @@ -674,6 +795,14 @@         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@@ -685,6 +814,52 @@     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"@@ -902,17 +1077,7 @@             RegistryEntry {export = foundExport, spec} <-               selectEntry reference entries requestedExport             pure (displayExport foundExport, spec)-        case compileProfile spec of-          Left definitionErrors ->-            dieText-              ( "Failed to load profile "-                  <> label-                  <> ": invalid profile definition:\n"-                  <> Text.intercalate-                    "\n"-                    (map (("  - " <>) . renderProfileDefinitionError) (toList definitionErrors))-              )-          Right compiled -> pure compiled+        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@@ -1158,30 +1323,19 @@   profileViolations <- case profilePath of     Nothing -> pure []     Just path -> do-      loaded <- loadProfileFile path-      case loaded of-        Left err -> dieText ("Failed to load profile " <> Text.pack path <> ": " <> err)-        Right spec ->-          case compileProfile spec of-            Left definitionErrors ->-              dieText-                ( "Failed to load profile "-                    <> Text.pack path-                    <> ": invalid profile definition:\n"-                    <> Text.intercalate "\n" (map (("  - " <>) . renderProfileDefinitionError) (toList definitionErrors))-                )-            Right compiled -> do-              -- 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+      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)@@ -1359,7 +1513,7 @@   LazyByteString.putStrLn (Aeson.encode (buildGraph concepts))  runShow :: ShowOptions -> IO ()-runShow ShowOptions {bundlePath, conceptIdText, profilePath, computationOnly} = do+runShow ShowOptions {bundlePath, conceptIdText, profilePath, computationOnly, conceptOrder} = do   fzfConfig <- detectFzfConfig   resolvedBundle <- resolveBundlePath fzfConfig bundlePath   concepts <- loadBundleOrExit resolvedBundle@@ -1370,7 +1524,7 @@   case conceptIdText of     Just rawIdentifier -> showConceptByIdentifier render profilePath concepts rawIdentifier     Nothing -> do-      selection <- selectConcept fzfConfig resolvedBundle concepts+      selection <- selectConcept fzfConfig conceptOrder resolvedBundle concepts       case selection of         ConceptChosen concept -> render concept         ConceptNoCandidates ->@@ -1567,6 +1721,178 @@         (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@@ -1685,6 +2011,26 @@   case loaded of     Left err -> dieText ("Failed to load profile " <> Text.pack profilePath <> ": " <> err)     Right spec -> pure spec++-- | Compile a loaded profile, or exit 1 listing the authoring contradictions+-- that stopped it. The label is how the profile was named on the command line: a+-- descriptor path, or a registry export.+--+-- Shared by every command that compiles a profile so that the wording cannot+-- drift between them.+compileProfileOrExit :: Text -> ProfileSpec -> IO CompiledProfile+compileProfileOrExit label spec =+  case compileProfile spec of+    Left definitionErrors ->+      dieText+        ( "Failed to load profile "+            <> label+            <> ": invalid profile definition:\n"+            <> Text.intercalate+              "\n"+              (map (("  - " <>) . renderProfileDefinitionError) (toList definitionErrors))+        )+    Right compiled -> pure compiled  loadBundleOrExit :: FilePath -> IO [Concept] loadBundleOrExit bundlePath = do
+ src/Okf/Cli/Agent/Config.hs view
@@ -0,0 +1,373 @@+-- | Deciding which agent setting wins, and remembering why.+--+-- Each configurable field resolves through one ordered list of candidate+-- sources; the first candidate holding a present, non-blank value wins and+-- carries its label with it. Two independent axes generate the middle of that+-- list. /Scope/ distinguishes the project-local configuration file from the+-- user's global one. /Specificity/ distinguishes a per-command key+-- (@agent.assist.model@) from a shared-default key (@agent.model@). The rule+-- connecting them is that __scope dominates across scopes, and specificity+-- dominates within a scope__: any local value beats any global value, and+-- within one file the per-command key beats the shared default. The opposite+-- reading — a global per-command key beating a local shared default — is+-- equally coherent, which is why the rule is written down here and printed by+-- @okf config agent@.+--+-- Resolution returns provenance alongside every value, because that is what+-- lets @okf config agent@ answer "why this model?" in one line instead of+-- making a user simulate these rules in their head.+module Okf.Cli.Agent.Config+  ( AgentCommandName (..),+    agentCommandSegment,+    allAgentCommands,+    AgentField (..),+    agentFieldSegment,+    agentDefaultKey,+    agentCommandKey,+    agentFieldFlag,+    agentFieldEnvVar,+    AgentConfigSource (..),+    agentSourceLabel,+    ResolvedField (..),+    AgentOverrides (..),+    noAgentOverrides,+    ResolvedAgent (..),+    resolveAgent,+    renderAgentResolution,+    parseOkfProvider,+    parseOkfEffort,+    thinkingLevelOf,+  )+where++import Baikai.ThinkingLevel (ThinkingLevel (..))+import Data.Maybe (listToMaybe)+import Data.Text qualified as Text+import Okf.Cli.Config+  ( AgentFieldSettings (..),+    AgentSettings (..),+    OkfEffort (..),+    OkfProvider (..),+    agentSharedDefaults,+    renderOkfEffort,+    renderOkfProvider,+  )+import Okf.Prelude++-- | The commands okf can launch a model from. @okf assist@ is the only one+-- today; the type exists so that adding a second is one constructor rather than+-- a redesign, and so the exhaustiveness checker makes sure the key builders and+-- the inspection table are extended with it.+data AgentCommandName = AgentCmdAssist+  deriving stock (Eq, Show, Enum, Bounded)++agentCommandSegment :: AgentCommandName -> Text+agentCommandSegment AgentCmdAssist = "assist"++allAgentCommands :: [AgentCommandName]+allAgentCommands = [minBound .. maxBound]++-- | The settings an agent-launching command carries.+data AgentField+  = ProviderField+  | ModelField+  | EffortField+  | SystemPromptField+  deriving stock (Eq, Show, Enum, Bounded)++-- | The field's name as a configuration key segment.+agentFieldSegment :: AgentField -> Text+agentFieldSegment = \case+  ProviderField -> "provider"+  ModelField -> "model"+  EffortField -> "effort"+  SystemPromptField -> "systemPrompt"++-- | The shared-default key, e.g. @agent.model@.+agentDefaultKey :: AgentField -> Text+agentDefaultKey agentField = "agent." <> agentFieldSegment agentField++-- | The per-command key, e.g. @agent.assist.model@.+agentCommandKey :: AgentCommandName -> AgentField -> Text+agentCommandKey command agentField =+  "agent." <> agentCommandSegment command <> "." <> agentFieldSegment agentField++-- | The long flag name, without leading dashes. Note this is not the key+-- segment: @systemPrompt@ is spelled @--system-prompt@ on the command line.+agentFieldFlag :: AgentField -> Text+agentFieldFlag = \case+  ProviderField -> "provider"+  ModelField -> "model"+  EffortField -> "effort"+  SystemPromptField -> "system-prompt"++-- | The environment variable, which is deliberately cross-command: an+-- environment variable is already a coarse, session-wide override, and a+-- per-command variable for every field would multiply the surface area for a+-- case the per-command configuration keys already serve better.+agentFieldEnvVar :: AgentField -> String+agentFieldEnvVar = \case+  ProviderField -> "OKF_AGENT_PROVIDER"+  ModelField -> "OKF_AGENT_MODEL"+  EffortField -> "OKF_AGENT_EFFORT"+  SystemPromptField -> "OKF_AGENT_SYSTEM_PROMPT"++-- | Where a resolved value came from, highest precedence first.+--+-- There is no "flag on the parent command" tier: @assist@ is a top-level+-- command with no parent that carries agent flags. If an @okf agent …@ group is+-- ever introduced, add a @SourceCliParent@ constructor between 'SourceCliFlag'+-- and 'SourceEnvVar'.+data AgentConfigSource+  = SourceCliFlag+  | SourceEnvVar+  | SourceLocalCommand+  | SourceLocalDefault+  | SourceGlobalCommand+  | SourceGlobalDefault+  | SourceBuiltinDefault+  deriving stock (Eq, Show)++-- | The label @okf config agent@ prints for a source.+agentSourceLabel :: AgentCommandName -> AgentField -> AgentConfigSource -> Text+agentSourceLabel command agentField = \case+  SourceCliFlag -> "--" <> agentFieldFlag agentField <> " flag"+  SourceEnvVar -> "env: " <> Text.pack (agentFieldEnvVar agentField)+  SourceLocalCommand -> "local: " <> agentCommandKey command agentField+  SourceLocalDefault -> "local: " <> agentDefaultKey agentField+  SourceGlobalCommand -> "global: " <> agentCommandKey command agentField+  SourceGlobalDefault -> "global: " <> agentDefaultKey agentField+  SourceBuiltinDefault -> "built-in default"++data ResolvedField a = ResolvedField+  { resolvedValue :: a,+    resolvedSource :: AgentConfigSource+  }+  deriving stock (Eq, Show)++-- | Values supplied by a layer above the configuration files. The caller has+-- already parsed and validated the text, so the resolver never sees a raw+-- string it might have to reject.+data AgentOverrides = AgentOverrides+  { provider :: !(Maybe OkfProvider),+    model :: !(Maybe Text),+    effort :: !(Maybe OkfEffort),+    systemPrompt :: !(Maybe Text)+  }+  deriving stock (Eq, Show)++noAgentOverrides :: AgentOverrides+noAgentOverrides =+  AgentOverrides+    { provider = Nothing,+      model = Nothing,+      effort = Nothing,+      systemPrompt = Nothing+    }++-- | What okf will actually use, and where each value came from.+--+-- 'provider' has a value in every case because a launcher has to pick one, so+-- it is a bare 'ResolvedField'. The other three are 'Maybe', where 'Nothing'+-- means unset and no source claimed it — which renders no flag at all, so an+-- unconfigured okf produces the command line it has always produced.+data ResolvedAgent = ResolvedAgent+  { provider :: !(ResolvedField OkfProvider),+    model :: !(Maybe (ResolvedField Text)),+    effort :: !(Maybe (ResolvedField OkfEffort)),+    systemPrompt :: !(Maybe (ResolvedField Text))+  }+  deriving stock (Eq, Show)++resolveAgent ::+  AgentCommandName ->+  -- | From command-line flags.+  AgentOverrides ->+  -- | From environment variables, already parsed.+  AgentOverrides ->+  -- | Local scope.+  Maybe AgentSettings ->+  -- | Global scope.+  Maybe AgentSettings ->+  ResolvedAgent+resolveAgent command flags env local global =+  ResolvedAgent+    { provider =+        fromMaybe (ResolvedField ProviderClaude SourceBuiltinDefault) $+          resolveField overrideProvider fieldProvider id,+      model = resolveField overrideModel fieldModel (>>= nonBlank),+      effort = resolveField overrideEffort fieldEffort id,+      systemPrompt = resolveField overrideSystemPrompt fieldSystemPrompt (>>= nonBlank)+    }+  where+    resolveField ::+      (AgentOverrides -> Maybe a) ->+      (AgentFieldSettings -> Maybe a) ->+      (Maybe a -> Maybe a) ->+      Maybe (ResolvedField a)+    resolveField fromOverrides fromBlock normalize =+      firstCandidate+        [ (normalize (fromOverrides flags), SourceCliFlag),+          (normalize (fromOverrides env), SourceEnvVar),+          (normalize (fromBlock . commandBlock command =<< local), SourceLocalCommand),+          (normalize (fromBlock . agentSharedDefaults =<< local), SourceLocalDefault),+          (normalize (fromBlock . commandBlock command =<< global), SourceGlobalCommand),+          (normalize (fromBlock . agentSharedDefaults =<< global), SourceGlobalDefault)+        ]++-- | The table behind @okf config agent@: one block per command, one row per+-- field, each row carrying the value okf resolved and the key or flag that+-- supplied it.+--+-- The precedence legend is printed unconditionally. The whole point of the+-- command is that the rules live next to the output that obeys them.+renderAgentResolution :: [(AgentCommandName, ResolvedAgent)] -> Text+renderAgentResolution entries =+  Text.unlines (concatMap renderBlock blocks <> [""] <> precedenceLegend)+  where+    blocks = [(command, agentResolutionRows command resolved) | (command, resolved) <- entries]+    allRows = concatMap snd blocks+    commandWidth = widest (map (agentCommandSegment . fst) blocks)+    fieldWidth = widest [name | (name, _, _) <- allRows]+    valueWidth = widest [value | (_, value, _) <- allRows]+    widest = foldr (max . Text.length) 0++    renderBlock (command, rows) =+      [ "  "+          <> pad commandWidth (if rowIndex == (0 :: Int) then agentCommandSegment command else "")+          <> "  "+          <> pad fieldWidth name+          <> "  "+          <> pad valueWidth value+          <> "  ["+          <> source+          <> "]"+      | (rowIndex, (name, value, source)) <- zip [0 ..] rows+      ]++    pad width text = text <> Text.replicate (max 0 (width - Text.length text)) " "++-- | One @(field, value, source label)@ row per configurable field.+--+-- A field no source claimed reads @(unset)@ and is attributed to the built-in+-- default, because that is what okf will use. Values are flattened to one line+-- so the table stays a table; a multi-line system prompt is shown with its line+-- breaks collapsed to spaces.+agentResolutionRows :: AgentCommandName -> ResolvedAgent -> [(Text, Text, Text)]+agentResolutionRows command ResolvedAgent {provider, model, effort, systemPrompt} =+  [ describe ProviderField (Just (rendered renderOkfProvider provider)),+    describe ModelField (rendered id <$> model),+    describe EffortField (rendered renderOkfEffort <$> effort),+    describe SystemPromptField (rendered id <$> systemPrompt)+  ]+  where+    rendered render ResolvedField {resolvedValue, resolvedSource} =+      ResolvedField {resolvedValue = oneLine (render resolvedValue), resolvedSource}++    describe agentField = \case+      Nothing ->+        ( agentFieldSegment agentField,+          "(unset)",+          agentSourceLabel command agentField SourceBuiltinDefault+        )+      Just ResolvedField {resolvedValue, resolvedSource} ->+        ( agentFieldSegment agentField,+          resolvedValue,+          agentSourceLabel command agentField resolvedSource+        )++    oneLine = Text.unwords . Text.words++precedenceLegend :: [Text]+precedenceLegend =+  [ "Precedence, highest first:",+    "  1. --provider / --model / --effort / --system-prompt flag on the subcommand",+    "  2. OKF_AGENT_PROVIDER / OKF_AGENT_MODEL / OKF_AGENT_EFFORT / OKF_AGENT_SYSTEM_PROMPT",+    "  3. local scope   agent.<command>.<field>",+    "  4. local scope   agent.<field>",+    "  5. global scope  agent.<command>.<field>",+    "  6. global scope  agent.<field>",+    "  7. built-in default"+  ]++-- | Walk the candidates and keep the first that holds a value, with its label.+firstCandidate :: [(Maybe a, AgentConfigSource)] -> Maybe (ResolvedField a)+firstCandidate candidates =+  listToMaybe [ResolvedField value source | (Just value, source) <- candidates]++-- | A key set to @"  "@ names no model, so treat it as absent rather than as a+-- model literally named two spaces. Only the text-valued fields need this;+-- 'OkfProvider' and 'OkfEffort' are closed enumerations by the time they reach+-- the resolver.+nonBlank :: Text -> Maybe Text+nonBlank raw =+  let stripped = Text.strip raw+   in if Text.null stripped then Nothing else Just stripped++commandBlock :: AgentCommandName -> AgentSettings -> AgentFieldSettings+commandBlock AgentCmdAssist AgentSettings {assist} = assist++fieldProvider :: AgentFieldSettings -> Maybe OkfProvider+fieldProvider AgentFieldSettings {provider} = provider++fieldModel :: AgentFieldSettings -> Maybe Text+fieldModel AgentFieldSettings {model} = model++fieldEffort :: AgentFieldSettings -> Maybe OkfEffort+fieldEffort AgentFieldSettings {effort} = effort++fieldSystemPrompt :: AgentFieldSettings -> Maybe Text+fieldSystemPrompt AgentFieldSettings {systemPrompt} = systemPrompt++overrideProvider :: AgentOverrides -> Maybe OkfProvider+overrideProvider AgentOverrides {provider} = provider++overrideModel :: AgentOverrides -> Maybe Text+overrideModel AgentOverrides {model} = model++overrideEffort :: AgentOverrides -> Maybe OkfEffort+overrideEffort AgentOverrides {effort} = effort++overrideSystemPrompt :: AgentOverrides -> Maybe Text+overrideSystemPrompt AgentOverrides {systemPrompt} = systemPrompt++-- | Parse a provider name, case-insensitively. The message names every value+-- okf accepts, because a user who spelled one wrong needs the list, not a+-- restatement of what they typed.+parseOkfProvider :: Text -> Either Text OkfProvider+parseOkfProvider raw = case Text.toLower (Text.strip raw) of+  "claude" -> Right ProviderClaude+  "codex" -> Right ProviderCodex+  _ ->+    Left+      ( "unknown provider "+          <> Text.pack (show raw)+          <> "; expected one of: claude, codex"+      )++parseOkfEffort :: Text -> Either Text OkfEffort+parseOkfEffort raw =+  case lookup (Text.toLower (Text.strip raw)) table of+    Just level -> Right level+    Nothing ->+      Left+        ( "unknown effort "+            <> Text.pack (show raw)+            <> "; expected one of: "+            <> Text.intercalate ", " (map fst table)+        )+  where+    table = [(renderOkfEffort level, level) | level <- [minBound .. maxBound]]++-- | The one place okf's configuration vocabulary meets Baikai's. It lives here+-- rather than in "Okf.Cli.Config" so configuration decoding stays free of+-- Baikai types, and a malformed level fails during Dhall decoding with a typed+-- error naming the allowed constructors.+thinkingLevelOf :: OkfEffort -> ThinkingLevel+thinkingLevelOf = \case+  EffortMinimal -> ThinkingMinimal+  EffortLow -> ThinkingLow+  EffortMedium -> ThinkingMedium+  EffortHigh -> ThinkingHigh+  EffortXHigh -> ThinkingXHigh+  EffortMax -> ThinkingMax
src/Okf/Cli/Assist.hs view
@@ -1,19 +1,41 @@ -- | The @okf assist@ command: launch an interactive agent session with -- installed okf skills on its path.+--+-- The vendor command line is rendered by Baikai, not here. okf describes the+-- session in Baikai's provider-neutral vocabulary and lets+-- @baikai-claude@ or @baikai-openai@ decide which flags express it, so a+-- vendor's spelling of a concept lives in one library rather than in every tool+-- that launches an agent. module Okf.Cli.Assist   ( AssistOptions (..),     assistOptionsParser,+    assistAgentOverrides,     handleAssistCommand,-    buildClaudeCommand,+    buildAgentCommand,   ) where +import Baikai.Agent (AgentRenderError, renderAgentRenderError)+import Baikai.Interactive (InteractiveLaunchRequest, interactiveLaunchRequest) import Baikai.Kit.Session (agentDirsForSession)+import Baikai.Provider.Claude.Interactive (claudeInteractiveCommand, defaultClaudeInteractiveConfig)+import Baikai.Provider.OpenAI.Interactive (codexInteractiveCommand, defaultCodexInteractiveConfig) import Control.Exception (IOException, try)+import Control.Lens ((&), (.~))+import Data.Bifunctor (first)+import Data.Generics.Labels () import Data.Text (Text) import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO-import Okf.Cli.Config (AssistSettings (..), OkfConfig (..), OkfProvider (..))+import Okf.Cli.Agent.Config+  ( AgentOverrides (..),+    ResolvedAgent (..),+    ResolvedField (..),+    parseOkfEffort,+    parseOkfProvider,+    thinkingLevelOf,+  )+import Okf.Cli.Config (OkfConfig, OkfEffort, OkfProvider (..)) import Okf.Cli.Kit.Config (kitConfig) import Options.Applicative import System.Exit (ExitCode (..), exitWith)@@ -22,7 +44,10 @@  data AssistOptions = AssistOptions   { prompt :: !Text,+    providerOverride :: !(Maybe OkfProvider),     modelOverride :: !(Maybe Text),+    effortOverride :: !(Maybe OkfEffort),+    systemPromptOverride :: !(Maybe Text),     printCommand :: !Bool   }   deriving stock (Show, Eq)@@ -32,54 +57,157 @@   AssistOptions     <$> (Text.pack <$> strArgument (metavar "PROMPT" <> help "The task or question to start the agent session with"))     <*> optional+      ( option+          (eitherReader (first Text.unpack . parseOkfProvider . Text.pack))+          ( long "provider"+              <> metavar "PROVIDER"+              <> help "Agent CLI to launch: claude or codex"+          )+      )+    <*> optional       ( Text.pack           <$> strOption (long "model" <> metavar "MODEL" <> help "Override the assist model from config")       )+    <*> optional+      ( option+          (eitherReader (first Text.unpack . parseOkfEffort . Text.pack))+          ( long "effort"+              <> metavar "LEVEL"+              <> help "Reasoning effort: minimal, low, medium, high, xhigh, or max"+          )+      )+    <*> optional+      ( Text.pack+          <$> strOption+            ( long "system-prompt"+                <> metavar "TEXT"+                <> help "Extra system prompt, appended to the agent's own"+            )+      )     <*> switch (long "print-command" <> help "Print the agent command line instead of launching it") --- | Build the @claude@ argv from config, discovered kit agent dirs, and command--- options. The prompt is the final positional argument.-buildClaudeCommand :: OkfConfig -> [FilePath] -> AssistOptions -> [String]-buildClaudeCommand-  OkfConfig {assist = AssistSettings {model = configModel, systemPrompt}}+-- | The flag layer of the precedence chain. Values are already parsed, so+-- @--effort medum@ fails during argument parsing with the list of valid levels+-- rather than reaching resolution as a string nobody can use.+assistAgentOverrides :: AssistOptions -> AgentOverrides+assistAgentOverrides+  AssistOptions {providerOverride, modelOverride, effortOverride, systemPromptOverride} =+    AgentOverrides+      { provider = providerOverride,+        model = modelOverride,+        effort = effortOverride,+        systemPrompt = systemPromptOverride+      }++-- | Everything that differs between the agent CLIs okf can launch.+--+-- The two vendors agree on how a model, extra readable directories, and an+-- initial prompt are expressed, and disagree on where a system prompt goes.+-- Holding that disagreement in one record keeps it readable, and keeps+-- @case provider of@ out of the rest of the module.+data InteractiveLauncher = InteractiveLauncher+  { -- | The executable's name, for error messages.+    launcherName :: !Text,+    -- | How to tell a user the CLI is missing.+    launcherInstallHint :: !Text,+    applySystemPrompt :: Maybe Text -> InteractiveLaunchRequest -> InteractiveLaunchRequest,+    buildCommand :: InteractiveLaunchRequest -> Either AgentRenderError (FilePath, [String])+  }++launcherFor :: OkfProvider -> InteractiveLauncher+launcherFor ProviderClaude =+  InteractiveLauncher+    { launcherName = "claude",+      launcherInstallHint = "Install Claude Code",+      -- Claude has a dedicated append flag that Baikai's request type does not+      -- model, and okf has always appended rather than replaced. Routing the+      -- text through the request's own systemPrompt field would render+      -- --system-prompt, which /replaces/ Claude's system prompt; that would+      -- silently change how every existing assist session behaves. Keep+      -- appending until Baikai can express the difference (baikai IR-4).+      applySystemPrompt = \mSystemPrompt request ->+        request & #extraArgs .~ maybe [] (\text -> ["--append-system-prompt", text]) mSystemPrompt,+      buildCommand = claudeInteractiveCommand defaultClaudeInteractiveConfig+    }+launcherFor ProviderCodex =+  InteractiveLauncher+    { launcherName = "codex",+      launcherInstallHint = "Install the Codex CLI",+      -- Codex has no system-prompt flag at all; Baikai folds this field into+      -- the prompt text ahead of the user's words. Routing it through extraArgs+      -- here would emit a flag Codex does not have.+      applySystemPrompt = \mSystemPrompt request -> request & #systemPrompt .~ mSystemPrompt,+      buildCommand = codexInteractiveCommand defaultCodexInteractiveConfig+    }++-- | Describe the session in Baikai's neutral vocabulary. Every field left+-- empty renders no flag at all, so an unconfigured okf produces the command+-- line it has always produced.+assistLaunchRequest ::+  InteractiveLauncher -> ResolvedAgent -> [FilePath] -> AssistOptions -> InteractiveLaunchRequest+assistLaunchRequest+  launcher+  ResolvedAgent {model, effort, systemPrompt}   agentDirs-  AssistOptions {prompt, modelOverride} =-    concatMap (\dir -> ["--add-dir", dir]) agentDirs-      ++ modelArgs-      ++ systemPromptArgs-      ++ [Text.unpack prompt]-    where-      chosenModel = case modelOverride of-        Nothing -> configModel-        Just override -> Just override-      modelArgs = maybe [] (\model -> ["--model", Text.unpack model]) chosenModel-      systemPromptArgs =-        maybe [] (\systemPromptText -> ["--append-system-prompt", Text.unpack systemPromptText]) systemPrompt+  AssistOptions {prompt} =+    applySystemPrompt launcher (resolvedValue <$> systemPrompt) $+      interactiveLaunchRequest prompt+        & #modelId .~ (resolvedValue <$> model)+        & #effort .~ (thinkingLevelOf . resolvedValue <$> effort)+        & #extraDirs .~ agentDirs -handleAssistCommand :: OkfConfig -> AssistOptions -> IO ()-handleAssistCommand config options =-  case provider (assist config) of-    ProviderCodex -> do-      hPutStrLn stderr "okf assist: the Codex provider is not yet supported; set assist.provider = Claude."+-- | Render the agent argv from the resolved settings, discovered kit agent+-- dirs, and command options. A 'Left' means the request asked for something the+-- chosen vendor cannot express, and no process should be started.+buildAgentCommand ::+  ResolvedAgent ->+  [FilePath] ->+  AssistOptions ->+  Either AgentRenderError (FilePath, [String])+buildAgentCommand resolved agentDirs options =+  let launcher = launcherFor (resolvedProviderOf resolved)+   in buildCommand launcher (assistLaunchRequest launcher resolved agentDirs options)++resolvedProviderOf :: ResolvedAgent -> OkfProvider+resolvedProviderOf ResolvedAgent {provider = ResolvedField {resolvedValue}} = resolvedValue++handleAssistCommand :: OkfConfig -> ResolvedAgent -> AssistOptions -> IO ()+handleAssistCommand config resolved options = do+  let launcher = launcherFor (resolvedProviderOf resolved)+  agentDirs <- agentDirsForSession (kitConfig config)+  case buildAgentCommand resolved agentDirs options of+    Left renderError -> do+      Text.IO.hPutStrLn stderr ("okf assist: " <> renderAgentRenderError renderError)       exitWith (ExitFailure 2)-    ProviderClaude -> do-      agentDirs <- agentDirsForSession (kitConfig config)-      let argv = buildClaudeCommand config agentDirs options-      if printCommand options-        then Text.IO.putStrLn (Text.pack (unwords ("claude" : map quoteArg argv)))-        else launchClaude argv+    Right (executable, argv)+      | printCommand options ->+          Text.IO.putStrLn (Text.pack (unwords (executable : map quoteArg argv)))+      | otherwise -> launchAgent launcher executable argv -launchClaude :: [String] -> IO ()-launchClaude argv = do+-- | Spawn the rendered command and wait for the session to end.+--+-- Baikai renders the command but okf still owns the spawn, because Baikai's own+-- launchers do not delegate Ctrl-C: @launchClaudeInteractive@ runs through+-- cradle, whose @delegateCtlc@ defaults to 'False', and+-- @launchCodexInteractive@ calls 'createProcess' without it. Without+-- delegation the terminal's SIGINT reaches okf as well as the agent, so the+-- first Ctrl-C inside a session would kill okf and orphan the agent. See+-- baikai IR-5.+launchAgent :: InteractiveLauncher -> FilePath -> [String] -> IO ()+launchAgent launcher executable argv = do   result <- try @IOException $ do-    (_, _, _, processHandle) <- createProcess (proc "claude" argv) {delegate_ctlc = True}+    (_, _, _, processHandle) <- createProcess (proc executable argv) {delegate_ctlc = True}     waitForProcess processHandle   case result of     Left exception -> do       hPutStrLn stderr $-        "okf assist: failed to launch claude: "+        "okf assist: failed to launch "+          <> Text.unpack (launcherName launcher)+          <> ": "           <> show exception-          <> "\nInstall Claude Code or run `okf assist --print-command ...` to inspect the command."+          <> "\n"+          <> Text.unpack (launcherInstallHint launcher)+          <> " or run `okf assist --print-command ...` to inspect the command."       exitWith (ExitFailure 127)     Right exitCode -> exitWith exitCode 
src/Okf/Cli/Config.hs view
@@ -1,15 +1,36 @@ -- | Project and global configuration for the okf CLI, loaded from Dhall.+--+-- Two resolution models live here, deliberately. Everything except the @agent@+-- block resolves /first file found wins/: 'findConfigSource' picks one file and+-- the others are never read. The @agent@ block instead resolves across two+-- scopes — 'findConfigScopes' and 'loadAgentScopes' return the project-local and+-- global files independently, so a project can override one agent field while+-- inheriting the rest from the user's global file. Layering the other blocks+-- would mean making every one of their fields optional, which breaks every+-- configuration file already written, in exchange for a merge of single values+-- that have no per-command dimension. module Okf.Cli.Config   ( OkfConfig (..),     KitSettings (..),-    AssistSettings (..),     ProfileSettings (..),+    AgentFieldSettings (..),+    AgentSettings (..),     OkfProvider (..),+    OkfEffort (..),     ConfigSource (..),+    ConfigScope (..),+    ConfigScopes (..),     defaultOkfConfig,+    defaultAgentSettings,+    emptyAgentFieldSettings,+    agentSharedDefaults,     loadOkfConfig,+    loadAgentScopes,     findConfigSource,+    findConfigScopes,     renderConfigSource,+    renderOkfEffort,+    renderOkfProvider,     exampleConfigText,     renderConfig,     okfConfigEnvVar,@@ -43,6 +64,37 @@     where       stripProviderPrefix name = fromMaybe name (Text.stripPrefix "Provider" name) +-- | How hard a reasoning-capable model should deliberate before answering.+--+-- Like 'OkfProvider' this is an okf-local enum rather than Baikai's+-- @ThinkingLevel@, so a misspelled level fails during Dhall decoding with a+-- typed error naming the allowed constructors. The conversion to Baikai's+-- vocabulary lives in "Okf.Cli.Agent.Config", at the point of use.+data OkfEffort+  = EffortMinimal+  | EffortLow+  | EffortMedium+  | EffortHigh+  | EffortXHigh+  | EffortMax+  deriving stock (Generic, Eq, Show, Enum, Bounded)++instance FromDhall OkfEffort where+  autoWith _normalizer =+    genericAutoWith+      (Dhall.defaultInterpretOptions {Dhall.constructorModifier = stripEffortPrefix})+    where+      stripEffortPrefix name = fromMaybe name (Text.stripPrefix "Effort" name)++renderOkfEffort :: OkfEffort -> Text+renderOkfEffort = \case+  EffortMinimal -> "minimal"+  EffortLow -> "low"+  EffortMedium -> "medium"+  EffortHigh -> "high"+  EffortXHigh -> "xhigh"+  EffortMax -> "max"+ -- | Kit-related settings: where to fetch skills/subagents and which providers -- to install for. data KitSettings = KitSettings@@ -52,8 +104,11 @@   deriving stock (Generic, Eq, Show)   deriving anyclass (FromDhall) --- | Assist-related settings: which provider to launch and optional overrides.-data AssistSettings = AssistSettings+-- | The @assist@ block okf carried before the @agent@ block replaced it. It is+-- no longer part of 'OkfConfig'; it survives only as a decode shape, so that a+-- configuration file written for an earlier release still loads and its values+-- are carried onto the keys that replaced them.+data LegacyAssistSettings = LegacyAssistSettings   { provider :: !OkfProvider,     model :: !(Maybe Text),     systemPrompt :: !(Maybe Text)@@ -68,21 +123,80 @@   deriving stock (Generic, Eq, Show)   deriving anyclass (FromDhall) +-- | The settings one agent-launching command can carry. Every field is+-- optional: an unset field means "this key claims nothing", which is what lets+-- a narrower key fall through to a broader one.+data AgentFieldSettings = AgentFieldSettings+  { provider :: !(Maybe OkfProvider),+    model :: !(Maybe Text),+    effort :: !(Maybe OkfEffort),+    systemPrompt :: !(Maybe Text)+  }+  deriving stock (Generic, Eq, Show)+  deriving anyclass (FromDhall)++-- | The @agent@ block: the same four fields as shared defaults, plus one+-- sub-record per agent-launching command.+data AgentSettings = AgentSettings+  { provider :: !(Maybe OkfProvider),+    model :: !(Maybe Text),+    effort :: !(Maybe OkfEffort),+    systemPrompt :: !(Maybe Text),+    assist :: !AgentFieldSettings+  }+  deriving stock (Generic, Eq, Show)+  deriving anyclass (FromDhall)++-- | Project the shared defaults into the same shape as a per-command block, so+-- the resolver can walk both with one candidate builder.+agentSharedDefaults :: AgentSettings -> AgentFieldSettings+agentSharedDefaults AgentSettings {provider, model, effort, systemPrompt} =+  AgentFieldSettings {provider, model, effort, systemPrompt}++emptyAgentFieldSettings :: AgentFieldSettings+emptyAgentFieldSettings =+  AgentFieldSettings+    { provider = Nothing,+      model = Nothing,+      effort = Nothing,+      systemPrompt = Nothing+    }++defaultAgentSettings :: AgentSettings+defaultAgentSettings =+  AgentSettings+    { provider = Nothing,+      model = Nothing,+      effort = Nothing,+      systemPrompt = Nothing,+      assist = emptyAgentFieldSettings+    }+ -- | The whole okf configuration. data OkfConfig = OkfConfig   { kit :: !KitSettings,-    assist :: !AssistSettings,+    agent :: !AgentSettings,     profiles :: !ProfileSettings   }   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+  }+  deriving stock (Generic, Eq, Show)+  deriving anyclass (FromDhall)+ -- | The configuration record as okf 0.2.0.0 defined it, before @profiles@ was--- added. Dhall decodes records strictly, so without this fallback every config--- file written for 0.2.0.0 would stop loading the moment a field was added.-data LegacyOkfConfig = LegacyOkfConfig+-- added.+data ConfigShapeV020 = ConfigShapeV020   { kit :: !KitSettings,-    assist :: !AssistSettings+    assist :: !LegacyAssistSettings   }   deriving stock (Generic, Eq, Show)   deriving anyclass (FromDhall)@@ -96,6 +210,18 @@   | SourceDefaults   deriving stock (Eq, Show) +-- | Which of the two layers a configuration file occupies. Any local value+-- beats any global one.+data ConfigScope = LocalScope | GlobalScope+  deriving stock (Eq, Show)++-- | The file, if any, occupying each scope.+data ConfigScopes = ConfigScopes+  { localSource :: !(Maybe FilePath),+    globalSource :: !(Maybe FilePath)+  }+  deriving stock (Eq, Show)+ defaultOkfConfig :: OkfConfig defaultOkfConfig =   OkfConfig@@ -104,12 +230,7 @@           { repoUrl = "https://github.com/shinzui/okf-kit.git",             providers = [ProviderClaude]           },-      assist =-        AssistSettings-          { provider = ProviderClaude,-            model = Nothing,-            systemPrompt = Nothing-          },+      agent = defaultAgentSettings,       profiles = defaultProfileSettings     } @@ -158,43 +279,123 @@       exists <- doesFileExist path       if exists then pure (mkSource path) else firstExisting rest +-- | Resolve both configuration scopes independently, for the settings that+-- layer rather than replace.+--+-- Note the deliberate consequence of the local rule: @OKF_CONFIG@ replaces the+-- project file but does not suppress the global one, because it names /a/ file+-- rather than /the only/ file.+findConfigScopes :: IO ConfigScopes+findConfigScopes = do+  mEnv <- lookupEnv okfConfigEnvVar+  envExists <- maybe (pure False) doesFileExist mEnv+  projectPath <- projectConfigPath+  localSource <- if envExists then pure mEnv else firstExistingFile [projectPath]+  xdgPath <- xdgConfigPath+  dotPath <- dotConfigPath+  globalSource <- firstExistingFile [xdgPath, dotPath]+  pure ConfigScopes {localSource, globalSource}++firstExistingFile :: [FilePath] -> IO (Maybe FilePath)+firstExistingFile [] = pure Nothing+firstExistingFile (path : rest) = do+  exists <- doesFileExist path+  if exists then pure (Just path) else firstExistingFile rest+ -- | Load the effective configuration and report its source. A parse or type -- error in a found file is returned as 'Left'; a missing file yields defaults.------ A file that does not decode against the current record is retried against the--- 0.2.0.0 shape, which had no @profiles@ field; on success the built-in default--- registry fills the gap, so upgrading okf does not invalidate a config file the--- user already wrote. If the retry also fails, the /first/ error is reported,--- because that message describes the schema the user should be writing against. loadOkfConfig :: IO (Either Text (OkfConfig, ConfigSource)) loadOkfConfig = do   configSource <- findConfigSource   case sourcePath configSource of     Nothing -> pure (Right (defaultOkfConfig, configSource))-    Just path -> do-      current <- tryDecode (Dhall.inputFile auto path)-      case current of-        Right config -> pure (Right (config, configSource))-        Left currentError -> do-          legacy <- tryDecode (Dhall.inputFile auto path)-          pure $ case legacy of-            Left _legacyError -> Left currentError-            Right LegacyOkfConfig {kit = legacyKit, assist = legacyAssist} ->-              Right-                ( OkfConfig-                    { kit = legacyKit,-                      assist = legacyAssist,-                      profiles = defaultProfileSettings-                    },-                  configSource-                )+    Just path -> fmap (,configSource) <$> decodeConfigFile path++-- | Load the @agent@ block from each scope. 'Nothing' for a scope means that+-- scope has no configuration file, not that its file set nothing.+loadAgentScopes :: IO (Either Text (Maybe AgentSettings, Maybe AgentSettings))+loadAgentScopes = do+  ConfigScopes {localSource, globalSource} <- findConfigScopes+  localResult <- loadScope LocalScope localSource+  case localResult of+    Left err -> pure (Left err)+    Right localAgent -> fmap (localAgent,) <$> loadScope GlobalScope globalSource   where+    loadScope _scope Nothing = pure (Right Nothing)+    loadScope scope (Just path) = do+      decoded <- decodeConfigFile path+      pure $ case decoded of+        Left err ->+          Left+            ( "Failed to load "+                <> renderConfigScope scope+                <> " config "+                <> Text.pack path+                <> ": "+                <> err+            )+        Right config -> Right (Just (agent config))++-- | Decode one configuration file, trying each record shape okf has written, in+-- order from newest to oldest, and filling the missing pieces from defaults.+--+-- A file that predates the @agent@ block has its @assist@ block mapped onto the+-- per-command @agent.assist@ keys, so a user who never edits their file keeps+-- exactly the behaviour they have today. If every shape fails, the /first/+-- error is reported, because that message describes the schema the user should+-- be writing against.+decodeConfigFile :: FilePath -> IO (Either Text OkfConfig)+decodeConfigFile path = do+  current <- tryDecode (Dhall.inputFile auto path)+  case current of+    Right config -> pure (Right 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+  where     tryDecode :: IO a -> IO (Either Text a)     tryDecode action =       (Right <$> action)         `catch` \(exception :: SomeException) ->           pure (Left (Text.pack (show exception))) +fromShapeWithoutAgent :: ConfigShapeWithoutAgent -> OkfConfig+fromShapeWithoutAgent ConfigShapeWithoutAgent {kit, assist, profiles} =+  OkfConfig {kit, agent = agentSettingsFromAssist assist, profiles}++fromShapeV020 :: ConfigShapeV020 -> OkfConfig+fromShapeV020 ConfigShapeV020 {kit, assist} =+  OkfConfig+    { kit,+      agent = agentSettingsFromAssist assist,+      profiles = defaultProfileSettings+    }++-- | Carry a pre-@agent@ @assist@ block onto the per-command keys that replaced+-- it. The old block was per-command by nature, so it maps onto+-- @agent.assist.*@ rather than onto the shared defaults.+agentSettingsFromAssist :: LegacyAssistSettings -> AgentSettings+agentSettingsFromAssist LegacyAssistSettings {provider, model, systemPrompt} =+  AgentSettings+    { provider = Nothing,+      model = Nothing,+      effort = Nothing,+      systemPrompt = Nothing,+      assist =+        AgentFieldSettings+          { provider = Just provider,+            model = model,+            effort = Nothing,+            systemPrompt = systemPrompt+          }+    }+ sourcePath :: ConfigSource -> Maybe FilePath sourcePath = \case   SourceEnv path -> Just path@@ -211,28 +412,41 @@   SourceDot path -> Text.pack path   SourceDefaults -> "(built-in defaults)" +renderConfigScope :: ConfigScope -> Text+renderConfigScope = \case+  LocalScope -> "local"+  GlobalScope -> "global"+ -- | Human-readable dump of the effective configuration. renderConfig :: OkfConfig -> Text renderConfig   OkfConfig     { kit = KitSettings {repoUrl, providers},-      assist = AssistSettings {provider, model, systemPrompt},+      agent = agentSettings@AgentSettings {assist = agentAssist},       profiles = ProfileSettings {registry}     } =     Text.unlines-      [ "kit.repoUrl     = " <> repoUrl,-        "kit.providers   = " <> renderProviders providers,-        "assist.provider = " <> renderProvider provider,-        "assist.model    = " <> fromMaybe "(unset)" model,-        "assist.systemPrompt = " <> fromMaybe "(unset)" systemPrompt,-        "profiles.registry = " <> registry-      ]+      ( [ "kit.repoUrl     = " <> repoUrl,+          "kit.providers   = " <> renderProviders providers+        ]+          <> renderAgentFields "agent." (agentSharedDefaults agentSettings)+          <> renderAgentFields "agent.assist." agentAssist+          <> ["profiles.registry = " <> registry]+      ) +renderAgentFields :: Text -> AgentFieldSettings -> [Text]+renderAgentFields keyPrefix AgentFieldSettings {provider, model, effort, systemPrompt} =+  [ keyPrefix <> "provider = " <> maybe "(unset)" renderOkfProvider provider,+    keyPrefix <> "model = " <> fromMaybe "(unset)" model,+    keyPrefix <> "effort = " <> maybe "(unset)" renderOkfEffort effort,+    keyPrefix <> "systemPrompt = " <> fromMaybe "(unset)" systemPrompt+  ]+ renderProviders :: [OkfProvider] -> Text-renderProviders providers = "[" <> Text.intercalate ", " (map renderProvider providers) <> "]"+renderProviders providers = "[" <> Text.intercalate ", " (map renderOkfProvider providers) <> "]" -renderProvider :: OkfProvider -> Text-renderProvider = \case+renderOkfProvider :: OkfProvider -> Text+renderOkfProvider = \case   ProviderClaude -> "claude"   ProviderCodex -> "codex" @@ -240,16 +454,29 @@ exampleConfigText :: Text exampleConfigText =   Text.unlines-    [ "-- okf configuration. See `okf config show` for the effective values.",+    [ "-- okf configuration. See `okf config show` for the effective values,",+      "-- and `okf config agent` for how each agent setting was resolved.",       "let Provider = < Claude | Codex >",+      "",+      "let Effort = < Minimal | Low | Medium | High | XHigh | Max >",+      "",       "in  { kit =",       "        { repoUrl = \"https://github.com/shinzui/okf-kit.git\"",       "        , providers = [ Provider.Claude ]",       "        }",-      "    , assist =",-      "        { provider = Provider.Claude",+      "    , agent =",+      "        -- Shared defaults for every agent-launching command.",+      "        { provider = None Provider",       "        , model = None Text",+      "        , effort = None Effort",       "        , systemPrompt = None Text",+      "        -- Per-command settings; these win over the shared defaults above.",+      "        , assist =",+      "            { provider = None Provider",+      "            , model = None Text",+      "            , effort = None Effort",+      "            , systemPrompt = None Text",+      "            }",       "        }",       "    , profiles =",       "        { registry = \"" <> defaultRegistryReference <> "\"",
src/Okf/Cli/Fzf/Selector.hs view
@@ -2,26 +2,37 @@ -- directory and a concept inside it. module Okf.Cli.Fzf.Selector   ( BundleSelection (..),+    ConceptOrder (..),     ConceptSelection (..),     bundleSearchRootsEnvVar,     parseBundleSearchRoots,+    parseConceptOrder,     bundleSearchRoots,     conceptCandidates,+    conceptModificationTimes,     conceptPreviewCommand,+    orderConcepts,+    renderConceptOrder,     selectBundle,     selectConcept,+    sortConceptsByModified,   ) where +import Control.Exception (IOException, try) import Data.List qualified as List import Data.Maybe (fromMaybe)+import Data.Ord (Down (..)) import Data.Text (Text) import Data.Text qualified as Text-import Okf.Bundle (Concept, conceptIdOf, conceptTitle, conceptType)+import Data.Time.Clock (UTCTime)+import Okf.Bundle (Concept, conceptIdOf, conceptSourcePath, conceptTitle, conceptType) import Okf.Cli.Fzf import Okf.ConceptId (renderConceptId) import Okf.Discovery (defaultDiscoveryOptions, discoverBundleRoots)+import System.Directory (getModificationTime) import System.Environment (getExecutablePath, lookupEnv)+import System.FilePath ((</>))  -- | Outcome of asking the user to pick a bundle. data BundleSelection@@ -44,6 +55,30 @@   | ConceptSelectionError !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+    -- means is usually one they were just working on.+    ByModifiedTime+  | -- | Alphabetical by concept ID, the order 'Okf.Bundle.walkBundle' returns.+    ByConceptId+  deriving stock (Show, Eq)++-- | Read a @--sort@ value. The spellings are the two nouns in the flag's help+-- text, and nothing else is accepted, so a typo fails the parse rather than+-- silently selecting the default.+parseConceptOrder :: String -> Maybe ConceptOrder+parseConceptOrder = \case+  "modified" -> Just ByModifiedTime+  "id" -> Just ByConceptId+  _ -> Nothing++-- | The @--sort@ spelling of an order, so help text and errors can name it.+renderConceptOrder :: ConceptOrder -> Text+renderConceptOrder = \case+  ByModifiedTime -> "modified"+  ByConceptId -> "id"+ -- | Colon-separated list of directories to search for bundles, in the style of -- @PATH@. bundleSearchRootsEnvVar :: String@@ -92,24 +127,66 @@             FzfCancelled -> BundleSelectionCancelled             FzfError message -> BundleSelectionError message -selectConcept :: FzfConfig -> FilePath -> [Concept] -> IO ConceptSelection-selectConcept fzfConfig bundlePath concepts+selectConcept :: FzfConfig -> ConceptOrder -> FilePath -> [Concept] -> IO ConceptSelection+selectConcept fzfConfig order bundlePath concepts   | not (isFzfAvailable fzfConfig) = pure ConceptSelectionUnavailable   | null concepts = pure ConceptNoCandidates   | otherwise = do       executablePath <- getExecutablePath+      -- @--no-sort@ is what makes fzf honour the order chosen here: without it+      -- fzf would impose its own on the unfiltered list.+      ordered <- orderConcepts order bundlePath concepts       let opts =             withPrompt "concept> "               <> withHeader (Text.pack bundlePath)               <> withHeight "60%"               <> withNoSort               <> withPreview (conceptPreviewCommand executablePath bundlePath)-      result <- runFzf fzfConfig opts (conceptCandidates concepts)+      result <- runFzf fzfConfig opts (conceptCandidates ordered)       pure $ case result of         FzfSelected concept -> ConceptChosen concept         FzfNoMatch -> ConceptNoCandidates         FzfCancelled -> ConceptSelectionCancelled         FzfError message -> ConceptSelectionError message++-- | Put the menu's concepts in the requested order.+--+-- Sorting by ID is what 'Okf.Bundle.walkBundle' already returns, and it is+-- redone here anyway so the menu's order is a property of the menu rather than+-- one inherited from a caller that could stop guaranteeing it. Only the+-- modification-time order touches the filesystem.+orderConcepts :: ConceptOrder -> FilePath -> [Concept] -> IO [Concept]+orderConcepts ByConceptId _ concepts =+  pure (List.sortOn (renderConceptId . conceptIdOf) concepts)+orderConcepts ByModifiedTime bundlePath concepts =+  sortConceptsByModified <$> conceptModificationTimes bundlePath concepts++-- | Pair every concept with the modification time of the file it was read+-- from, resolved against the bundle root because 'conceptSourcePath' is+-- bundle-relative.+--+-- A file that cannot be stat'd -- deleted between the walk and the menu, or+-- unreadable -- yields 'Nothing' rather than an exception: an unknown timestamp+-- costs the concept its place in the ordering, never the whole menu.+conceptModificationTimes :: FilePath -> [Concept] -> IO [(Concept, Maybe UTCTime)]+conceptModificationTimes bundlePath =+  traverse $ \concept -> do+    modified <-+      try @IOException (getModificationTime (bundlePath </> conceptSourcePath concept))+    pure (concept, either (const Nothing) Just modified)++-- | Most recently modified first, with concepts whose time is unknown last.+--+-- Concept ID breaks ties, so bundles written in one checkout -- where every+-- file shares a timestamp -- still get the stable alphabetical order+-- 'Okf.Bundle.walkBundle' guarantees rather than an arbitrary one.+sortConceptsByModified :: [(Concept, Maybe UTCTime)] -> [Concept]+sortConceptsByModified = map fst . List.sortOn sortKey+  where+    -- 'Down' over 'Maybe' reverses both halves at once: later times sort before+    -- earlier ones, and 'Just' before 'Nothing'.+    sortKey (concept, modified) =+      (Down modified, renderConceptId (conceptIdOf concept))  -- | One candidate per concept, displayed as three tab-separated columns -- -- concept ID, type, title -- with the first two padded so the list lines up.
src/Okf/Cli/Help.hs view
@@ -49,6 +49,7 @@     HelpTopic "validation" "How bundles are validated and referential integrity" validationTopicContent,     HelpTopic "profiles" "Checking a bundle against house conventions" profilesTopicContent,     HelpTopic "computations" "Listing and printing attested computations" computationsTopicContent,+    HelpTopic "concepts" "Listing and filtering the concepts in a bundle" conceptsTopicContent,     HelpTopic "trust" "Trust tiers, staleness, and recorded provenance" trustTopicContent,     HelpTopic "index" "Generated index.md files and the version declaration" indexTopicContent,     HelpTopic "log" "log.md upkeep and the two staleness checks" logTopicContent,@@ -74,6 +75,9 @@  computationsTopicContent :: Text computationsTopicContent = $(embedStringFile "help/computations.md")++conceptsTopicContent :: Text+conceptsTopicContent = $(embedStringFile "help/concepts.md")  trustTopicContent :: Text trustTopicContent = $(embedStringFile "help/trust.md")
test/Main.hs view
@@ -5,21 +5,25 @@ import Data.List qualified as List import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO-import Okf.Bundle (bundleInventoryOfConcepts, conceptAttester, conceptExecutor, conceptFromDocument, conceptIdOf, conceptParameters, conceptRuntime, conceptType, walkBundle, walkBundleInventory)+import Data.Time.Calendar (fromGregorian)+import Data.Time.Clock (UTCTime (..))+import Okf.Bundle (Concept, bundleInventoryOfConcepts, conceptAttester, conceptExecutor, conceptFromDocument, conceptIdOf, conceptParameters, conceptRuntime, conceptType, walkBundle, walkBundleInventory) import Okf.Cli-import Okf.Cli.Assist (AssistOptions (..), buildClaudeCommand)-import Okf.Cli.Config (AssistSettings (..), ConfigSource (..), KitSettings (..), OkfConfig (..), OkfProvider (..), defaultOkfConfig, exampleConfigText, findConfigSource, loadOkfConfig, okfConfigEnvVar, projectConfigPath)+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 (conceptCandidates, conceptPreviewCommand, parseBundleSearchRoots)+import Okf.Cli.Fzf.Selector (ConceptOrder (..), conceptCandidates, conceptPreviewCommand, orderConcepts, parseBundleSearchRoots) import Okf.Cli.Help (HelpTopic (..), helpTopics) import Okf.ConceptId (parseConceptId, renderConceptId) import Okf.Document (Attester (..), Executor (..), Parameter (..), parseDocument) import Okf.Index (OkfVersion (..), VersionDeclaration (..), parseOkfVersion, readBundleVersion) import Okf.Profile (Cardinality (..), FieldCondition (..), FieldFormat (..), FieldRule (..), FrontmatterRules (..), HandleReferenceRule (..), NestedFieldRule (..), NestedRules (..), PathReferenceRule (..), ProfileSpec (..), TypeRule (..), compileProfile, loadProfileFile, validateProfile, validateProfileVersion)-import Okf.Profile.Registry (RegistryEntry (..))+import Okf.Profile.Registry (RegistryEntry (..), 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, withCurrentDirectory)+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getCurrentDirectory, getTemporaryDirectory, listDirectory, removeDirectoryRecursive, setModificationTime, withCurrentDirectory) import System.Environment (lookupEnv, setEnv, unsetEnv) import System.Exit (exitFailure) import System.FilePath ((</>))@@ -32,9 +36,12 @@   configProjectPrecedence <- testConfigProjectPrecedence   configEnvPrecedence <- testConfigEnvPrecedence   configLegacyWithoutProfiles <- testConfigLegacyWithoutProfiles+  configLegacyWithoutAgent <- testConfigLegacyWithoutAgent+  agentScopesLoadsBothFiles <- testAgentScopesLoadsBothFiles   configInvalidDhall <- testConfigInvalidDhall   assistCommandBuilder <- testAssistCommandBuilder   assistModelOverride <- testAssistModelOverride+  assistCodexCommandBuilder <- testAssistCodexCommandBuilder   profileDocumentWrites <- testProfileDocumentWritesBundle   profileDocumentDeclaresVersion <- testProfileDocumentDeclaresOkfVersion   profileDocMatchesExample <- testProfileDocumentationMatchesCommittedExample@@ -45,7 +52,12 @@   exampleAttestedComputation <- testExampleAttestedComputationValidates   computationsReportsFixtures <- testComputationsReportsFixtureBundle   computationsReportsExample <- testComputationsReportsExampleBundle+  conceptsReportsFixtures <- testConceptsReportsFixtureBundle+  conceptsShowsFilteredColumns <- testConceptsShowsFilteredColumns+  conceptsReportsExample <- testConceptsReportsExampleBundle+  conceptsKeepsStatusDefaultOut <- testConceptsDoesNotApplyStatusDefault   profileDocStrictWithTimestamp <- testProfileDocumentationStrictWithTimestamp+  conceptMenuOrdering <- testConceptMenuOrdering   let results =         [ parseSucceeds ["validate", "bundle"],           parseSucceeds ["validate", "bundle", "--strict"],@@ -112,7 +124,8 @@               { bundlePath = Just "bundle",                 conceptIdText = Just "tables/orders",                 profilePath = Nothing,-                computationOnly = False+                computationOnly = False,+                conceptOrder = ByModifiedTime               },           parseShowMatches             ["show", "b", "ADR-2", "--profile", "p.dhall"]@@ -120,7 +133,8 @@               { bundlePath = Just "b",                 conceptIdText = Just "ADR-2",                 profilePath = Just "p.dhall",-                computationOnly = False+                computationOnly = False,+                conceptOrder = ByModifiedTime               },           parseShowMatches             ["show"]@@ -128,7 +142,8 @@               { bundlePath = Nothing,                 conceptIdText = Nothing,                 profilePath = Nothing,-                computationOnly = False+                computationOnly = False,+                conceptOrder = ByModifiedTime               },           parseShowMatches             ["show", "bundle"]@@ -136,7 +151,8 @@               { bundlePath = Just "bundle",                 conceptIdText = Nothing,                 profilePath = Nothing,-                computationOnly = False+                computationOnly = False,+                conceptOrder = ByModifiedTime               },           parseShowMatches             ["show", "--profile", "p.dhall"]@@ -144,7 +160,8 @@               { bundlePath = Nothing,                 conceptIdText = Nothing,                 profilePath = Just "p.dhall",-                computationOnly = False+                computationOnly = False,+                conceptOrder = ByModifiedTime               },           -- §10.3's two forms are both reachable through one flag, so a caller           -- does not have to know which one the producer chose.@@ -154,8 +171,31 @@               { bundlePath = Just "bundle",                 conceptIdText = Just "computations/revenue",                 profilePath = Nothing,-                computationOnly = True+                computationOnly = True,+                conceptOrder = ByModifiedTime               },+          -- --sort takes the menu back to the order walkBundle returns, and a+          -- misspelled order fails the parse rather than falling back to the+          -- default, which would be invisible.+          parseShowMatches+            ["show", "bundle", "--sort", "id"]+            ShowOptions+              { bundlePath = Just "bundle",+                conceptIdText = Nothing,+                profilePath = Nothing,+                computationOnly = False,+                conceptOrder = ByConceptId+              },+          parseShowMatches+            ["show", "--sort", "modified"]+            ShowOptions+              { bundlePath = Nothing,+                conceptIdText = Nothing,+                profilePath = Nothing,+                computationOnly = False,+                conceptOrder = ByModifiedTime+              },+          parseFails ["show", "bundle", "--sort", "mtime"],           parseIdMatches             ["id", "next", "b", "ADR", "--profile", "p.dhall"]             IdOptions@@ -195,7 +235,9 @@           parseSucceeds ["help"],           parseSucceeds ["help", "okf"],           parseSucceeds ["help", "format"],+          parseSucceeds ["help", "concepts"],           any ((== "okf") . topicName) helpTopics,+          any ((== "concepts") . topicName) helpTopics,           all (not . Text.null . topicContent) helpTopics,           optsToArgs (withPrompt "bundle> " <> withHeight "40%" <> withNoSort)             == ["--prompt", "bundle> ", "--height", "40%", "--no-sort"],@@ -319,6 +361,62 @@           parseSucceeds ["sources", "bundle"],           parseSucceeds ["computations", "bundle"],           parseFails ["computations"],+          parseSucceeds ["concepts", "bundle"],+          -- The BUNDLE argument is required: this command never launches fzf.+          parseFails ["concepts"],+          parseConceptsMatches+            ["concepts", "b"]+            ConceptsOptions+              { bundlePath = "b",+                conceptTypes = [],+                fieldFilters = [],+                presentFields = [],+                absentFields = [],+                showFields = [],+                profilePath = Nothing,+                json = False+              },+          parseConceptsMatches+            ["concepts", "b", "--json"]+            ConceptsOptions+              { bundlePath = "b",+                conceptTypes = [],+                fieldFilters = [],+                presentFields = [],+                absentFields = [],+                showFields = [],+                profilePath = Nothing,+                json = True+              },+          parseConceptsMatches+            ["concepts", "b", "--type", "Policy", "--where", "status=accepted", "--show", "requestId"]+            ConceptsOptions+              { bundlePath = "b",+                conceptTypes = ["Policy"],+                fieldFilters = [FieldEquals (TopLevelField "status") "accepted"],+                presentFields = [],+                absentFields = [],+                showFields = ["requestId"],+                profilePath = Nothing,+                json = False+              },+          parseConceptsMatches+            ["concepts", "b", "--has", "completedAt", "--missing", "reviews.outcome"]+            ConceptsOptions+              { bundlePath = "b",+                conceptTypes = [],+                fieldFilters = [],+                presentFields = [TopLevelField "completedAt"],+                absentFields = [NestedField "reviews" "outcome"],+                showFields = [],+                profilePath = Nothing,+                json = False+              },+          -- A filter is rejected before the bundle is walked: no '=' at all, and+          -- a key nesting deeper than one level.+          parseFails ["concepts", "b", "--where", "status"],+          parseFails ["concepts", "b", "--where", "a.b.c=x"],+          parseFails ["concepts", "b", "--has", "a.b.c"],           renderRegistryTable sampleRegistryEntries == sampleRegistryTable,           renderProfileDetail "nested.decisions" sampleDecisionsProfile == sampleProfileDetail,           renderProfileDetail "" samplePostgresqlProfile == sampleUndocumentedProfileDetail,@@ -336,14 +434,35 @@           exampleAttestedComputation,           computationsReportsFixtures,           computationsReportsExample,+          conceptsReportsFixtures,+          conceptsShowsFilteredColumns,+          conceptsReportsExample,+          conceptsKeepsStatusDefaultOut,           profileDocStrictWithTimestamp,+          conceptMenuOrdering,           configDefaults,           configProjectPrecedence,           configEnvPrecedence,           configLegacyWithoutProfiles,+          configLegacyWithoutAgent,+          agentScopesLoadsBothFiles,           configInvalidDhall,           assistCommandBuilder,-          assistModelOverride+          assistModelOverride,+          assistCodexCommandBuilder,+          testAssistEffortReachesEachVendor,+          testAssistUnconfiguredRendersNoFlags,+          testAgentFlagBeatsEverything,+          testAgentEnvBeatsBothScopes,+          testAgentCommandKeyBeatsDefaultKeyInScope,+          testAgentLocalDefaultBeatsGlobalCommandKey,+          testAgentGlobalCommandKeyBeatsGlobalDefaultKey,+          testAgentBuiltinDefaults,+          testAgentBlankValueFallsThrough,+          testAgentEffortParseError,+          testAgentEffortParsesEveryLevel,+          testAgentProviderParsing,+          testAgentResolutionFormatter         ]   unless (and results) exitFailure @@ -356,11 +475,15 @@   where     longConcept = buildConcept "tables/orders" "---\ntype: Table\ntitle: Orders\n---\n\n# Orders\n"     shortConcept = buildConcept "x" "---\ntype:\n---\n\n# x\n"-    buildConcept idText source =-      case (parseConceptId idText, parseDocument source) of-        (Right conceptId, Right document) -> conceptFromDocument conceptId document-        _ -> error ("sample concept did not parse: " <> Text.unpack idText) +-- | 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+buildConcept idText source =+  case (parseConceptId idText, parseDocument source) of+    (Right conceptId, Right document) -> conceptFromDocument conceptId document+    _ -> error ("sample concept did not parse: " <> Text.unpack idText)+ parseSucceeds :: [String] -> Bool parseSucceeds args =   case execParserPure defaultPrefs parserInfo args of@@ -767,6 +890,12 @@     Success (Options (Id opts)) -> opts == expected     _ -> False +parseConceptsMatches :: [String] -> ConceptsOptions -> Bool+parseConceptsMatches args expected =+  case execParserPure defaultPrefs parserInfo args of+    Success (Options (Concepts opts)) -> opts == expected+    _ -> False+ parseShowMatches :: [String] -> ShowOptions -> Bool parseShowMatches args expected =   case execParserPure defaultPrefs parserInfo args of@@ -1106,6 +1235,92 @@     ("examples" </> "ddd-ordering")     ["computations/order-total  postgres  order_id (uuid, required)  inline  executor + attester"] +-- | @okf concepts@ with no filters over the concept-filter fixture bundle: one+-- row per concept, three columns, sorted by concept ID. The column widths are+-- the fixture's own — @requests\/gamma@ at 14 and @Improvement Request@ at 19 —+-- so the padding is actually exercised, and the untyped-looking @Note@ row+-- proves the type column is padded rather than the title.+testConceptsReportsFixtureBundle :: IO Bool+testConceptsReportsFixtureBundle =+  assertConceptReport+    "okf concepts over the concept-filter fixture"+    ("okf-core" </> "test" </> "fixtures" </> "concept-filters")+    []+    id+    [ "notes/scratch   Note                 Scratch",+      "requests/alpha  Improvement Request  Alpha",+      "requests/beta   Improvement Request  Beta",+      "requests/gamma  Improvement Request  Gamma"+    ]++-- | @okf concepts --type 'Improvement Request' --show status@ over the fixture+-- bundle: the type filter drops the one @Note@, and the extra column sits+-- between @type@ and @title@ and pads to @completed@, its widest value.+testConceptsShowsFilteredColumns :: IO Bool+testConceptsShowsFilteredColumns =+  assertConceptReport+    "okf concepts --type 'Improvement Request' --show status"+    ("okf-core" </> "test" </> "fixtures" </> "concept-filters")+    ["status"]+    (filterConcepts [FieldEquals (TopLevelField "type") "Improvement Request"])+    [ "requests/alpha  Improvement Request  accepted   Alpha",+      "requests/beta   Improvement Request  proposed   Beta",+      "requests/gamma  Improvement Request  completed  Gamma"+    ]++-- | @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.+testConceptsReportsExampleBundle :: IO Bool+testConceptsReportsExampleBundle =+  assertConceptReport+    "okf concepts examples/ddd-ordering --type Policy"+    ("examples" </> "ddd-ordering")+    []+    (filterConcepts [FieldEquals (TopLevelField "type") "Policy"])+    [ "policies/issue-invoice-on-order  Policy  Issue Invoice On Order",+      "policies/reserve-stock           Policy  Reserve Stock"+    ]++-- | The result that surprises people, pinned: @examples\/ddd-ordering@ has+-- twenty-two concepts and only three say @status: stable@ in frontmatter, so+-- those three are what @--where status=stable@ selects. Seeing eighteen more+-- rows here would mean OKF v0.2's "absent means stable" default had leaked into+-- a command that reports frontmatter and nothing else.+testConceptsDoesNotApplyStatusDefault :: IO Bool+testConceptsDoesNotApplyStatusDefault =+  assertConceptReport+    "okf concepts examples/ddd-ordering --where status=stable --show status"+    ("examples" </> "ddd-ordering")+    ["status"]+    (filterConcepts [FieldEquals (TopLevelField "status") "stable"])+    [ "aggregates/order           Aggregate             stable  Order",+      "computations/order-total   Attested Computation  stable  Order total for a placed order",+      "metrics/order-total-value  Metric                stable  Order total value"+    ]++-- | Assert the exact lines @okf concepts@ prints for a repository bundle, after+-- an optional selection over the walked concepts.+assertConceptReport ::+  String -> FilePath -> [Text.Text] -> ([Concept] -> [Concept]) -> [Text.Text] -> IO Bool+assertConceptReport label relativeBundle shown select expected =+  withRepositoryPath label relativeBundle $ \bundleRoot -> do+    walked <- walkBundle bundleRoot+    case walked of+      Left bundleError -> do+        putStrLn ("failed to walk " <> relativeBundle <> ": " <> show bundleError)+        pure False+      Right concepts -> do+        let actual = conceptReport shown (select concepts)+        unless (actual == expected) $+          putStrLn+            ( "unexpected okf concepts report for "+                <> label+                <> ":\n"+                <> unlines (map Text.unpack actual)+            )+        pure (actual == expected)+ assertComputationReport :: FilePath -> [Text.Text] -> IO Bool assertComputationReport relativeBundle expected =   withRepositoryPath ("okf computations over " <> relativeBundle) relativeBundle $ \bundleRoot -> do@@ -1289,6 +1504,45 @@         && "* **Update**: Refreshed schema" `Text.isInfixOf` written     ) +-- | 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+-- --sort modified instead of aborting the walk, and take its alphabetical place+-- under --sort id, where no file is consulted.+testConceptMenuOrdering :: IO Bool+testConceptMenuOrdering = do+  temporaryDirectory <- getTemporaryDirectory+  root <- createTempDirectory temporaryDirectory "okf-cli-concept-order"+  createDirectoryIfMissing True (root </> "tables")+  let write name = Text.IO.writeFile (root </> "tables" </> (name <> ".md")) (conceptSource name)+      conceptSource name =+        Text.unlines ["---", "type: Table", "---", "", "# " <> Text.pack name]+      stamp name day =+        setModificationTime+          (root </> "tables" </> (name <> ".md"))+          (UTCTime (fromGregorian 2026 6 day) 0)+  mapM_ write ["alpha", "beta", "gamma"]+  stamp "alpha" 1+  stamp "beta" 20+  stamp "gamma" 10+  walked <- walkBundle root+  (byTime, byId) <- case walked of+    Left _ -> pure ([], [])+    Right walkedConcepts -> do+      -- Appended rather than inserted in ID order, so sorting by ID has to move+      -- it and cannot pass by leaving the input alone.+      let concepts = walkedConcepts <> [buildConcept "tables/delta" "---\ntype: Table\n---\n\n# delta\n"]+      recent <- orderConcepts ByModifiedTime root concepts+      alphabetical <- orderConcepts ByConceptId root concepts+      pure (map conceptIdText recent, map conceptIdText alphabetical)+  removeDirectoryRecursive root+  pure+    ( byTime == ["tables/beta", "tables/gamma", "tables/alpha", "tables/delta"]+        && byId == ["tables/alpha", "tables/beta", "tables/delta", "tables/gamma"]+    )+  where+    conceptIdText = renderConceptId . conceptIdOf+ testConfigDefaults :: IO Bool testConfigDefaults =   withIsolatedConfigEnv "okf-cli-config-defaults" $ do@@ -1326,7 +1580,7 @@     projectPath <- projectConfigPath     Text.IO.writeFile projectPath legacyConfigText     loaded <- loadOkfConfig-    pure (loaded == Right (defaultOkfConfig, SourceProject projectPath))+    pure (loaded == Right (configWithMappedAssist Nothing, SourceProject projectPath))  -- | Verbatim okf 0.2.0.0 configuration: the record before @profiles@ existed. legacyConfigText :: Text.Text@@ -1345,6 +1599,111 @@       "    }"     ] +-- | A config file written for the release before @agent@ existed has @kit@,+-- @assist@, and @profiles@. Its per-command @assist@ block must be carried onto+-- the @agent.assist@ keys that replaced it, so a user who never edits their file+-- keeps the model they configured.+testConfigLegacyWithoutAgent :: IO Bool+testConfigLegacyWithoutAgent =+  withIsolatedConfigEnv "okf-cli-config-legacy-agent" $ do+    projectPath <- projectConfigPath+    Text.IO.writeFile projectPath legacyWithProfilesConfigText+    loaded <- loadOkfConfig+    pure (loaded == Right (configWithMappedAssist (Just "legacy-model"), SourceProject projectPath))++-- | The shape okf wrote after @profiles@ arrived and before @agent@ did.+legacyWithProfilesConfigText :: Text.Text+legacyWithProfilesConfigText =+  Text.unlines+    [ "let Provider = < Claude | Codex >",+      "in  { kit =",+      "        { repoUrl = \"https://github.com/shinzui/okf-kit.git\"",+      "        , providers = [ Provider.Claude ]",+      "        }",+      "    , assist =",+      "        { provider = Provider.Claude",+      "        , model = Some \"legacy-model\"",+      "        , systemPrompt = None Text",+      "        }",+      "    , profiles =",+      "        { registry = \"" <> defaultRegistryReference <> "\"",+      "        }",+      "    }"+    ]++-- | Defaults everywhere, except that the old @assist@ block has been mapped+-- onto @agent.assist@. The provider is 'Just' rather than 'Nothing' because the+-- old field was required, so every such file states a provider.+configWithMappedAssist :: Maybe Text.Text -> OkfConfig+configWithMappedAssist legacyModel =+  defaultOkfConfig+    { agent =+        AgentSettings+          { provider = Nothing,+            model = Nothing,+            effort = Nothing,+            systemPrompt = Nothing,+            assist =+              AgentFieldSettings+                { provider = Just ProviderClaude,+                  model = legacyModel,+                  effort = Nothing,+                  systemPrompt = Nothing+                }+          }+    }++-- | The project file no longer hides the global one. Both scopes are read, and+-- the resolver decides between them; before this, the project file winning meant+-- the global file was never opened at all.+testAgentScopesLoadsBothFiles :: IO Bool+testAgentScopesLoadsBothFiles =+  withIsolatedConfigEnv "okf-cli-agent-scopes" $ do+    projectPath <- projectConfigPath+    Text.IO.writeFile projectPath (agentModelConfigText "local-model")+    home <- getCurrentDirectory+    let globalPath = home </> ".config" </> "okf" </> "config.dhall"+    createDirectoryIfMissing True (home </> ".config" </> "okf")+    Text.IO.writeFile globalPath (agentModelConfigText "global-model")+    scopes <- loadAgentScopes+    pure $+      case scopes of+        Left _ -> False+        Right (localAgent, globalAgent) ->+          fmap sharedModel localAgent == Just (Just "local-model")+            && fmap sharedModel globalAgent == Just (Just "global-model")+  where+    sharedModel settings = case agentSharedDefaults settings of+      AgentFieldSettings {model} -> model++-- | A current-shape configuration file that sets only @agent.model@.+agentModelConfigText :: Text.Text -> Text.Text+agentModelConfigText modelName =+  Text.unlines+    [ "let Provider = < Claude | Codex >",+      "let Effort = < Minimal | Low | Medium | High | XHigh | Max >",+      "in  { kit =",+      "        { repoUrl = \"https://github.com/shinzui/okf-kit.git\"",+      "        , providers = [ Provider.Claude ]",+      "        }",+      "    , agent =",+      "        { provider = None Provider",+      "        , model = Some \"" <> modelName <> "\"",+      "        , effort = None Effort",+      "        , systemPrompt = None Text",+      "        , assist =",+      "            { provider = None Provider",+      "            , model = None Text",+      "            , effort = None Effort",+      "            , systemPrompt = None Text",+      "            }",+      "        }",+      "    , profiles =",+      "        { registry = \"" <> defaultRegistryReference <> "\"",+      "        }",+      "    }"+    ]+ testConfigInvalidDhall :: IO Bool testConfigInvalidDhall =   withIsolatedConfigEnv "okf-cli-config-invalid" $ do@@ -1356,48 +1715,123 @@         Left message -> not (Text.null (Text.strip message))         Right _ -> False +-- | Baikai fixes the argument order — model, effort, system prompt, --add-dir+-- pairs, safety, extra arguments — and fences the prompt off behind @--@+-- because @--add-dir@ is variadic. testAssistCommandBuilder :: IO Bool testAssistCommandBuilder =   pure $-    buildClaudeCommand assistTestConfig ["/a", "/b"] (AssistOptions "do work" Nothing False)-      == [ "--add-dir",-           "/a",-           "--add-dir",-           "/b",-           "--model",-           "claude-opus-4-5",-           "--append-system-prompt",-           "Be concise",-           "do work"-         ]+    buildAgentCommand (assistTestAgent ProviderClaude Nothing) ["/a", "/b"] (assistPrompt "do work")+      == Right+        ( "claude",+          [ "--model",+            "claude-opus-4-5",+            "--add-dir",+            "/a",+            "--add-dir",+            "/b",+            "--append-system-prompt",+            "Be concise",+            "--",+            "do work"+          ]+        ) +-- | A resolved model is a resolved model however it was resolved, so this now+-- asserts that the builder renders whatever won rather than that the flag beats+-- the file; that ordering is 'resolveAgent'\'s job and is tested there. testAssistModelOverride :: IO Bool testAssistModelOverride =   pure $-    buildClaudeCommand assistTestConfig [] (AssistOptions "do work" (Just "override-model") True)-      == [ "--model",-           "override-model",-           "--append-system-prompt",-           "Be concise",-           "do work"-         ]+    buildAgentCommand (resolvedAgentWith ProviderClaude (Just "override-model") Nothing (Just "Be concise")) [] (assistPrompt "do work")+      == Right+        ( "claude",+          [ "--model",+            "override-model",+            "--append-system-prompt",+            "Be concise",+            "--",+            "do work"+          ]+        ) -assistTestConfig :: OkfConfig-assistTestConfig =-  defaultOkfConfig-    { assist =-        AssistSettings-          { provider = ProviderClaude,-            model = Just "claude-opus-4-5",-            systemPrompt = Just "Be concise"-          },-      kit =-        KitSettings-          { repoUrl = "file:///tmp/okf-kit",-            providers = [ProviderClaude]-          }+-- | Codex has no system-prompt flag, so the system prompt must reach it inside+-- the prompt argument rather than as @--append-system-prompt@. The exact+-- wording of the fold is Baikai's, so assert on what okf is responsible for —+-- choosing the field — rather than on a format okf does not own.+testAssistCodexCommandBuilder :: IO Bool+testAssistCodexCommandBuilder =+  pure $+    case buildAgentCommand (assistTestAgent ProviderCodex Nothing) ["/a"] (assistPrompt "do work") of+      Left _ -> False+      Right (executable, argv) ->+        executable == "codex"+          && take 5 argv == ["--model", "claude-opus-4-5", "--add-dir", "/a", "--"]+          && length argv == 6+          && "--append-system-prompt" `notElem` argv+          && systemPromptPrecedesUserPrompt (Text.pack (last argv))+  where+    systemPromptPrecedesUserPrompt folded =+      case Text.breakOn "do work" folded of+        (beforeUserPrompt, fromUserPrompt) ->+          not (Text.null fromUserPrompt)+            && "Be concise" `Text.isInfixOf` beforeUserPrompt++-- | One neutral effort level, two correct vendor renderings, and no vendor+-- knowledge in okf: Claude Code has no @minimal@ level so Baikai clamps it up to+-- @low@, while Codex accepts all six spellings and gets it verbatim.+testAssistEffortReachesEachVendor :: Bool+testAssistEffortReachesEachVendor =+  claudeArgs EffortMax == Just ["--effort", "max"]+    && claudeArgs EffortMinimal == Just ["--effort", "low"]+    && codexArgs EffortMax == Just ["-c", "model_reasoning_effort=max"]+    && codexArgs EffortMinimal == Just ["-c", "model_reasoning_effort=minimal"]+  where+    claudeArgs level = effortArgsOf ProviderClaude level+    codexArgs level = effortArgsOf ProviderCodex level+    effortArgsOf okfProvider level =+      case buildAgentCommand (assistTestAgent okfProvider (Just level)) [] (assistPrompt "x") of+        Left _ -> Nothing+        -- The model arguments come first and the effort arguments straight+        -- after, so drop the two model ones and keep the next two.+        Right (_, argv) -> Just (take 2 (drop 2 argv))++-- | Nothing configured anywhere renders no flags at all beyond the agent+-- directories, so upgrading okf cannot change anyone's token spend.+testAssistUnconfiguredRendersNoFlags :: Bool+testAssistUnconfiguredRendersNoFlags =+  buildAgentCommand (resolvedAgentWith ProviderClaude Nothing Nothing Nothing) ["/a"] (assistPrompt "x")+    == Right ("claude", ["--add-dir", "/a", "--", "x"])++assistPrompt :: Text.Text -> AssistOptions+assistPrompt promptText =+  AssistOptions+    { prompt = promptText,+      providerOverride = Nothing,+      modelOverride = Nothing,+      effortOverride = Nothing,+      systemPromptOverride = Nothing,+      printCommand = False     } +assistTestAgent :: OkfProvider -> Maybe OkfEffort -> ResolvedAgent+assistTestAgent okfProvider level =+  resolvedAgentWith okfProvider (Just "claude-opus-4-5") level (Just "Be concise")++-- | A 'ResolvedAgent' as the resolver would produce it. The sources are+-- arbitrary here: the command builder reads values, not provenance.+resolvedAgentWith ::+  OkfProvider -> Maybe Text.Text -> Maybe OkfEffort -> Maybe Text.Text -> ResolvedAgent+resolvedAgentWith okfProvider modelName level systemPromptText =+  ResolvedAgent+    { provider = ResolvedField {resolvedValue = okfProvider, resolvedSource = SourceLocalCommand},+      model = resolvedLocal <$> modelName,+      effort = resolvedLocal <$> level,+      systemPrompt = resolvedLocal <$> systemPromptText+    }+  where+    resolvedLocal resolvedValue = ResolvedField {resolvedValue, resolvedSource = SourceLocalCommand}+ withIsolatedConfigEnv :: String -> IO Bool -> IO Bool withIsolatedConfigEnv name runTest = do   temporaryDirectory <- getTemporaryDirectory@@ -1420,6 +1854,158 @@     setMaybeEnv key = \case       Nothing -> unsetEnv key       Just envValue -> setEnv key envValue++-- Agent resolution. Every case below is one tier of the precedence chain+-- printed by @okf config agent@; together they pin the whole ordering, so+-- reordering any two candidate entries in 'resolveAgent' fails a named test.++-- | An 'AgentSettings' with the given shared-default and per-command models.+agentScopeWithModels :: Maybe Text.Text -> Maybe Text.Text -> AgentSettings+agentScopeWithModels sharedModel commandModel =+  AgentSettings+    { provider = Nothing,+      model = sharedModel,+      effort = Nothing,+      systemPrompt = Nothing,+      assist = AgentFieldSettings {provider = Nothing, model = commandModel, effort = Nothing, systemPrompt = Nothing}+    }++modelOverrides :: Maybe Text.Text -> AgentOverrides+modelOverrides modelName = AgentOverrides {provider = Nothing, model = modelName, effort = Nothing, systemPrompt = Nothing}++-- | The resolved model and the label okf would print for where it came from.+resolvedModelWithSource ::+  AgentOverrides ->+  AgentOverrides ->+  Maybe AgentSettings ->+  Maybe AgentSettings ->+  Maybe (Text.Text, Text.Text)+resolvedModelWithSource flags env local global =+  case resolveAgent AgentCmdAssist flags env local global of+    ResolvedAgent {model = Nothing} -> Nothing+    ResolvedAgent {model = Just ResolvedField {resolvedValue, resolvedSource}} ->+      Just (resolvedValue, agentSourceLabel AgentCmdAssist ModelField resolvedSource)++-- | A flag beats an environment variable, which beats every file.+testAgentFlagBeatsEverything :: Bool+testAgentFlagBeatsEverything =+  resolvedModelWithSource+    (modelOverrides (Just "from-flag"))+    (modelOverrides (Just "from-env"))+    (Just (agentScopeWithModels (Just "local-default") (Just "local-command")))+    (Just (agentScopeWithModels (Just "global-default") (Just "global-command")))+    == Just ("from-flag", "--model flag")++testAgentEnvBeatsBothScopes :: Bool+testAgentEnvBeatsBothScopes =+  resolvedModelWithSource+    noAgentOverrides+    (modelOverrides (Just "from-env"))+    (Just (agentScopeWithModels (Just "local-default") (Just "local-command")))+    (Just (agentScopeWithModels (Just "global-default") (Just "global-command")))+    == Just ("from-env", "env: OKF_AGENT_MODEL")++-- | Within one file, specificity wins: the per-command key beats the shared+-- default.+testAgentCommandKeyBeatsDefaultKeyInScope :: Bool+testAgentCommandKeyBeatsDefaultKeyInScope =+  resolvedModelWithSource+    noAgentOverrides+    noAgentOverrides+    (Just (agentScopeWithModels (Just "local-default") (Just "local-command")))+    Nothing+    == Just ("local-command", "local: agent.assist.model")++-- | The single most important case in the milestone: across scopes, scope wins+-- — a local /shared default/ beats a global /per-command/ key. A reasonable+-- person could read the two axes the other way round, which is exactly why this+-- is asserted rather than assumed.+testAgentLocalDefaultBeatsGlobalCommandKey :: Bool+testAgentLocalDefaultBeatsGlobalCommandKey =+  resolvedModelWithSource+    noAgentOverrides+    noAgentOverrides+    (Just (agentScopeWithModels (Just "local-default") Nothing))+    (Just (agentScopeWithModels (Just "global-default") (Just "global-command")))+    == Just ("local-default", "local: agent.model")++testAgentGlobalCommandKeyBeatsGlobalDefaultKey :: Bool+testAgentGlobalCommandKeyBeatsGlobalDefaultKey =+  resolvedModelWithSource+    noAgentOverrides+    noAgentOverrides+    Nothing+    (Just (agentScopeWithModels (Just "global-default") (Just "global-command")))+    == Just ("global-command", "global: agent.assist.model")++-- | Nothing set anywhere: the model stays unset so no flag is rendered, and the+-- provider falls back to the one okf has always launched.+testAgentBuiltinDefaults :: Bool+testAgentBuiltinDefaults =+  resolvedModelWithSource noAgentOverrides noAgentOverrides Nothing Nothing == Nothing+    && case resolveAgent AgentCmdAssist noAgentOverrides noAgentOverrides Nothing Nothing of+      ResolvedAgent {provider = ResolvedField {resolvedValue, resolvedSource}} ->+        resolvedValue == ProviderClaude+          && agentSourceLabel AgentCmdAssist ProviderField resolvedSource == "built-in default"++-- | A key set to whitespace names no model, so it falls through to the next+-- candidate rather than resolving to a model called "  ".+testAgentBlankValueFallsThrough :: Bool+testAgentBlankValueFallsThrough =+  resolvedModelWithSource+    noAgentOverrides+    noAgentOverrides+    (Just (agentScopeWithModels (Just "   ") (Just "\t ")))+    (Just (agentScopeWithModels (Just "global-default") Nothing))+    == Just ("global-default", "global: agent.model")++-- | A misspelled level must name all six, because the list is what the user+-- needs and a restatement of their typo is not.+testAgentEffortParseError :: Bool+testAgentEffortParseError =+  case parseOkfEffort "medum" of+    Right _ -> False+    Left message ->+      message == "unknown effort \"medum\"; expected one of: minimal, low, medium, high, xhigh, max"++testAgentEffortParsesEveryLevel :: Bool+testAgentEffortParsesEveryLevel =+  map parseOkfEffort ["minimal", "LOW", " Medium ", "high", "xhigh", "max"]+    == map Right [EffortMinimal, EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax]++-- | Assert on substrings rather than the whole block, so changing a column+-- width does not break the test — but assert that the value and its source are+-- on the /same/ line, because a table that pairs a value with the wrong+-- provenance is worse than no table.+testAgentResolutionFormatter :: Bool+testAgentResolutionFormatter =+  lineContaining "provider" == Just "  assist  provider      claude           [built-in default]"+    && lineContaining "model" == Just "          model         claude-opus-4-8  [local: agent.assist.model]"+    && lineContaining "effort" == Just "          effort        max              [env: OKF_AGENT_EFFORT]"+    && lineContaining "systemPrompt" == Just "          systemPrompt  (unset)          [built-in default]"+    && "Precedence, highest first:" `elem` renderedLines+    && "  7. built-in default" `elem` renderedLines+  where+    renderedLines = Text.lines (renderAgentResolution [(AgentCmdAssist, mixedSourceAgent)])+    lineContaining needle = List.find (Text.isInfixOf needle) renderedLines++-- | One resolved agent with a different source behind every field.+mixedSourceAgent :: ResolvedAgent+mixedSourceAgent =+  ResolvedAgent+    { provider = ResolvedField {resolvedValue = ProviderClaude, resolvedSource = SourceBuiltinDefault},+      model = Just ResolvedField {resolvedValue = "claude-opus-4-8", resolvedSource = SourceLocalCommand},+      effort = Just ResolvedField {resolvedValue = EffortMax, resolvedSource = SourceEnvVar},+      systemPrompt = Nothing+    }++testAgentProviderParsing :: Bool+testAgentProviderParsing =+  parseOkfProvider "Codex" == Right ProviderCodex+    && parseOkfProvider "claude" == Right ProviderClaude+    && case parseOkfProvider "gemini" of+      Right _ -> False+      Left message -> message == "unknown provider \"gemini\"; expected one of: claude, codex"  parseFails :: [String] -> Bool parseFails args =