Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
1.0.0-rc.4 - 2026-09-21
The release candidate of the Studio. Since 1.0.0-rc.3: Orkeon Studio takes the team
journey end to end on a real crew — the wizard, the folders that travel with a team
(STUDIO-14, VFS-90: every settings mount carries a ULID, a crew names the entries it uses,
--mount-id selects one), the Tools and MCP tabs, the Run screen that shows the task in
progress, and « Modify » on any team, session or not (FORGE-09 forge reopen); the
provider fleet reaches 16 with the two aggregators OpenRouter and Mammouth AI (LLM-09);
the output cap follows each model's documented maximum instead of 4096 (LLM-10); a
timed-out call is a failed call and the sequential mode skips what depended on it
(LLM-11); the scripting runtime runs its loops in JavaScript (SCR-25); Orkeon.Hosting.Aspire
(ADR-011) and Orkeon.Interop.AgentFramework (ADR-010) join the lineup, and a run speaks
the OpenTelemetry GenAI conventions; the README's quickstart is executed by CI, the
provenance chain is documented and closed with an SBOM; and a second full SonarQube
campaign closes the 87 issues the fortnight had accumulated — 0 bug, 0 code smell, 0
hotspot to review, debt back to zero.
The public API surface is frozen at this tag: the 335 additions and 19 removals
accumulated since rc.3 move from PublicAPI.Unshipped.txt to PublicAPI.Shipped.txt
across the twelve projects that carried them.
Fixed — a host without an Llm section runs on the echo provider again
- The fallback the warning announces is the fallback the run gets. Since LLM-11 a call
the provider refuses fails the task with the provider's own sentence — the right thing for
a timed-out cloud model, and the wrong thing for a host with no
Llmsection at all: its provider was the infrastructure's keyless OpenAI default, so "NoLlmsection configured — falling back to the echo provider" was followed by "OpenAI API key is required" and exit code 2. The bundled scripting demos and the offline E2E run had stopped running.RunnerHostnow registers the echo provider itself (UndefinedLlmProvider, the one the scripting facade answers<undefined-llm>with) asIBasicLlmProviderandIChatClientwhen the section is absent; the crew runs, its answers replay the prompts, and the warning stays the one line that says it is not a real model (RunnerHostLlmFallbackWarningTests.MissingLlmSection_RunsOnTheEchoProvider).
Changed — the SonarQube campaign of 2026-09-21 (87 issues, 2 hotspots)
- Every issue the analysis raised on the code written since the 5 September campaign is
closed: the two
S2583bugs ofToolCallTextParser(the JSON block scanner is now an explicitBlockScanstate with one method per transition), the sixteen methods above the cognitive-complexity ceiling (MountSelection.Resolve,RunnerHostconfiguration,SequentialProcessStrategy,ChatClientAgentLoop,CrewHandoffDetector,TeamCatalog, the Studio view models, …), and the rest. Behaviour is unchanged except for one real defect the analysis caught: the shell refreshed the mounts editor's team references on every card added to the My-teams list instead of once per rebuild. MountSelectionPlan.WithdrawnIndices,TeamSummary.UnknownMountIdsandTeamTarget.UnknownMountIdsare methods now — a property must not copy a collection.ImportTeamViewModeltakes anImportTeamDependenciesrecord, like the other Studio screens.McpToolProvideris sealed. Every false-positive arbitration lives in the code, as a[SuppressMessage]or#pragmawith its justification (13 × S3604 on primary constructors, 2 × S1168 third-state nulls, the JS-called members ofJsLlmFacade, the YamlDotNet-populated probe, the vendors' key-console URLs).
Fixed — Studio: My teams and the wizard — a discarded session resets the wizard, « Modify » reaches it at the first click
- A session deleted under « Sessions in progress » no longer lives on in the wizard.
« Modify » on a team card parks its session in that list, « Resume » opens the wizard on
it, and deleting the row there left the wizard exactly where it was — a Composer over a
directory that no longer existed, every gesture on it bound to fail. The My-teams screen
now announces which session went (
TeamsViewModel.SessionDeleted, not raised for a delete the disk refused), the shell relays it, and the wizard open on that directory goes back to the blank step 1 of « Restart », a running engine stopped first (CreateTeamViewModel.ForgetSession). A session the wizard is not open on leaves it untouched; paths are compared the catalog's way (full, trailing-separator-blind, case-blind on Windows). - « Modify » brings the wizard forward on the first click. With no session pointing at
the team (imported, or its session deleted), the engine rebuilds one first (FORGE-09), and
the screen only came forward once it had — a second or two of nothing, so the button got
clicked again, and the second click landed on the busy engine and vanished. The wizard
now takes the screen on the click and the rebuild shows as the engine working. A click on
« Modify » or « Resume » while the engine is busy on another creation is refused in words
on the wizard's status line (
Studio.Create.EngineBusy, five languages), the creation under way untouched, instead of being dropped. - « Modify » no longer lands on step 1 with the rebuilt session sitting on disk. The
real cause of the second click: WPF resumes an await begun in an input handler at Send
priority — every window message is dispatched through an Invoke at Send — above the
Normal priority the reader thread's posts travel at, so the rebuild read the session off a
model the engine's events had not reached yet, found nothing, and stayed on step 1 while
the session it had just written waited on disk for the next click. A forge run's task now
completes only once its epilogue has landed on the UI thread, and with it every event
posted before (
CreateTeamViewModel.PostAndAwaitAsync, the adoption's promote too). The disk is the fallback when the stream announces nothing, a failure card says so when neither has it, and a resume or reopen that throws anything lands on the wizard's status line instead of a discarded task.
Fixed — Together's context window is no longer sent as the output cap (LLM-10, campaign of 2026-09-21)
TogetherAiLlmProvidersends the 4096 fallback again, not the model's window. LLM-10 named the window as the cap on Together (131 072 onLlama-3.3-70B-Instruct-Turbo, up to 1 048 575 onGLM-5.3-Flash) on the assumption thatcontext_length_exceeded_behavior: truncateclamps it to window − prompt. The campaign of 2026-09-21 measured otherwise: only one of Together's serverless engines honours the flag, and on the buffered path only — every other call was refused (inputs tokens + max_new_tokens must be <= 131073,context_length_exceeded), and the default model fell from 9/1/2 to 2/9/1 for two days. The fourtogether:entries are withdrawn fromLlmModelOutputLimits; the flag stays, it costs nothing and helps where it is honoured. Omitting the field would be worse: Together then stops at 2048 tokens (finish_reason: length, measured the same day).- The catalogue-cap retry net knows Together's wordings. A 400/422 naming
max_new_tokens,context_length_exceededor "maximum context length" now counts as the cap being refused, so a long prompt on the fallback is retried once without the field instead of failing (OpenAICompatibleProviderBase.TryDropCatalogueOutputCap). - Campaign kit.
gpt-6-astrais pinned totemperature: 1inllmproviders-test/lib/catalog.json(Only the default (1) value is supported) and, unlike Sol, has noreasoning_effort: none(Supported values are: 'low', 'medium', 'high', and 'xhigh'): function tools stay refused on chat/completions (use /v1/responses). Both facts are indocs/reference/llm-providers-comparison.md, per-model table.
Added — « Modify » without a session: orkeon forge reopen <team-folder> rebuilds one from the team's files (FORGE-09)
- A promoted team no longer needs its forge session to be modified.
orkeon forge reopen <team-folder>(offline,--eventsits only option) names the session whosepromotedTois the folder when one exists, and otherwise rebuilds one from the folder itself: the plan is read back fromcrew/(per-entity or single-file YAML) into a blueprint, the brief comes from the newforge.json, the crew is copied verbatim, and the session lands at the--drypause withpromotedToset —resume --edit --dry,resume,resume --adoptand apromote --toonto the same folder follow as usual (ForgeSessionRebuilder,ForgeTeamReader,ForgeTrigger.Rebuilt, eventteam.reopened {slug, dir, path, state, rebuilt, brief}). A folder with no readable YAML crew is refused withFORGE-TEAM-UNREADABLEand the reasons. forge promotewritesforge.jsonnext toFORGE.md: the session's slug, title, format, promotion instant and the brief — the machine-readable twin of the card, nothing secret in it. Without it (teams promoted earlier, imported crews) the rebuild derives a minimal brief from the plan and saysbrief: derived.- Orkeon Studio. « Modify » on a team card is enabled for every team whose
crew/holds a YAML definition (TeamSummary.HasYamlCrew), session or not; without a session the wizard runsforge reopenon the folder, reads the rebuilt session off the stream and opens the Composer at the dry pause (agents editable, « Try the team » / « Adopt without trying », adoption fields seeded from the sidecar, re-adoption pinned to the folder). The tooltip says whether the reopen goes through the session or a rebuilt one; only a team with no YAML crew keeps the button disabled, with the reason (Studio.Teams.ModifyRebuild,Studio.Teams.ModifyNoSessionreworded, five cultures).
Added — the Launch screen's command can be copied, and « Open the result » opens every writable folder
- Orkeon Studio's COMMANDE well is selectable and « Copy the command » puts the exact
orkeon run …line on the clipboard, ULID and all; the button reads « Command copied » until the command changes. Reproducing a Studio launch in a terminal no longer means retyping it. - « Open the result » opens one Explorer window per folder the run could write to — the team's
own folders first, then the declared entries kept for the run — and reads « Open the N result
folders » when there are several. It reads the effective mounts, so a settings entry withdrawn
for the run (its root went to the entry the team names) is never opened; the history cards
resolve a
--mount-idagainst the declared list the same way.
Added — every settings mount carries a ULID; a crew names the entries it uses (mounts:), two entries may share a root, and --mount-id selects one for the run (VFS-90)
- A mount has an identity. An entry of
Orkeon:FileSystem:Mountsmay carry a 26-character ULID before a|—01J9Z3K4M5N6P7Q8R9S0T1V2W3|C:\data:/output:rw(FileSystemMount.Id,MountId, strict Crockford validation ahead of the lenientUlid.Parse). Two entries may declare one virtual root when both carry an id; a root declared twice with an entry that has none, or one id on two entries, is refused before any host (RunnerExecution.EnsureVirtualRootsAreUnique,MountSelection.ValidateDeclared). - A crew names the roots it uses.
config.yaml/crew.yamlgain amounts:block —/output, or<ulid>|/outputto pin one settings entry (CrewConfiguration.Mounts,MountReference);forge promotewrites it. The block selects and validates, it never restricts (D-05):orkeon run crew/resolves it against the settings with no--mount, and refuses in one line a root nothing provides or an id no entry carries. --mount-id <ulid>(orkeon run, both dialects; several after one flag) keeps the entry named among several entries of one root. Precedence (D-10):--mounton the root replaces every entry of that root, else--mount-id, else the crew'smounts:, else a unique root is mounted as it is; with nothing selecting one, the run is refused naming every id (D-04). An entry not kept is withdrawn for the run — written tonullat its own index, not whitelisted, its folder not probed.RunnerHostapplies the sameMountSelection.Resolvewhile composing the configuration, so a host built without the guards (the daemon,rag,forge, tests) throws the same text instead of "Duplicate virtual paths".- Orkeon Studio: every authorized folder shows its id (copy button, « used by » the teams
naming it, a confirmation before removing one a team depends on); a save gives an id to an
entry that has none. The chooser opened for a mount point offers only the entries declared
under that root and records the pick verbatim; the disk pick declares the folder under the
row's root — a second
/output, told apart by its id — instead of renaming it/docs. The sidecar keeps<ulid>|<copy>, the ids travel with a duplicated, exported or imported team, a team naming a declaration missing on this machine is refused and the import review offers « Authorize them as recorded »; the launch lays--mount-idfor a declaration and--mountonly for the team's own folders; the effective-mounts table says which entry of a shared root is kept. The two folder modals (« Add an allowed folder », « Folders of … ») show the whole disk path, a one-word rights pill with the full label as tooltip, and the tail of each entry's id, so two/outputrows are told apart.
Changed
|at the head of a mount string is reserved for the id prefix; a physical path that really starts with a bare token and a|is quoted ("a|b":/x:ro).list_mountsand the access-denied messages never show an id (D-08).- The settings half of the duplicate-root guard reads
Orkeon:FileSystem:Mountswith its indices (RunnerSettings.ReadDeclaredAgentFacingMounts);EnsureMountSourcesExistskips the entries a selection withdrew; the runners pre-read the crew'smounts:block from disk (CrewMountDeclarations, anEXCEPTION-BOOTSTRAP) so the refusal comes in one line before "Using settings". - The
orkeon-hostdaemon stays out of the selection (D-11): per-crew roots, an id prefix on an operator--mountparses, no crew block is read. Astudio-team.jsonwritten with ids is not read by an Orkeon Studio from before this change (same version, no migration needed the other way: a sidecar without ids resolves by folder, root and rights and is upgraded on its next save). - Dependencies (2026-09-21): the
Microsoft.Extensions.*family and itsSystem.*companions to 10.0.12,Microsoft.Extensions.AIto 10.10.0,Microsoft.Agents.AI.Abstractionsto 1.22.0,Aspire.Hostingto 13.5.4,Microsoft.Data.SqlClient7.1.0,MongoDB.Driver3.12.0,Gremlin.Net3.8.2,StackExchange.Redis3.3.0,AngleSharp1.8.2,Polly8.8.0,Markdig1.4.0, theIdentityModeltrio 8.23.0,Azure.Security.KeyVault.Secrets4.11.1,AWSSDK.SecretsManager4.0.100.14,OpenTelemetry1.19.0,Microsoft.ML.OnnxRuntime1.30.0,Terminal.Gui2.5.0,Jint4.16.3; on the test sideMicrosoft.NET.Test.Sdk18.10.1,xunit.v34.0.1 andTestcontainers4.15.0.Orkeon.Compliance.Vfsis built against Roslyn 5.9.0, the compiler the repo runs, instead of 4.8.0;Orkeon.HostreferencesMicrosoft.CodeAnalysis.CSharpdirectly. Both solutions build warning-free on the new pins and the unit and fast suites are green.
Fixed — a timed-out LLM call is a failed call, not an empty answer; the sequential mode skips the dependents of a failed task; Studio pins the thinking switch and a reasoning-model timeout (LLM-11)
- A call the provider never answered fails the task with the provider's reason. On the
owner's run of 2026-09-20 (
kimi-k2.6,Llm:TimeoutSeconds180), the scorer's call timed out, the chat-client adapter mapped the refusal to an empty message, the agent loop diagnosed "a reasoning model out of budget — raiseLlm:MaxTokens" and retried once without tools, for a second full timeout: six minutes, zero tokens, wrong advice. Now the provider's failure travels asLlmResponse.Error(LlmResponseMetadataKeys), the bufferedLlmProviderToChatClientAdapterpath throws it the way the streaming path already did, and the loops exitAgentExitReason.LlmCallFailedat once — no tool-free retry — with the provider's sentence as the task's error, the summary line and the runner's last stderr line. - An elapsed timeout says which setting elapsed and the ways out. In place of HttpClient's
wording, every provider (
HttpLlmProviderBase.DescribeCallFailure) answers "{Provider} did not answer withinLlm:TimeoutSeconds= 180 s … raiseLlm:TimeoutSeconds(600 s is a safe value for a reasoning model), or turn thinking off", naming the settings key, the crewllm.thinking.enabledblock and the Studio switch. - An HttpClient timeout is retried — once. The retry clause written as
!ex.CancellationToken.IsCancellationRequestednever fired on a real timeout (HttpClient cancels its own linked token), so every timeout failed on its first attempt whateverLlm:MaxRetriessaid.ResiliencePolicies.IsHttpClientTimeoutrecognises the shape (a nestedTimeoutException); the LLM policy retries it once (ResilienceDefaults.LlmTimeoutRetries— every attempt costs the whole timeout, the ordinary budget is for failures that come back in seconds), never whenMaxRetriesis 0, never on a caller's cancellation; the generic HTTP policies retry it like any transient failure. - The sequential mode skips the dependents of a task that did not succeed. The writer
used to run on a context saying "Task failed: …" where the scored JSON should have been, and
improvised a deliverable from the cleaner's prose. A task whose declared dependency failed
or was skipped is now skipped in its turn — no agent asked, no token spent — recorded as
⊘ skippedinAUTO_SUMMARY.md(with a Skipped count), astask.completedwithskipped: trueon the event stream (RunTaskProgress.Skippedin Studio), and in the crew's failure reason; the tasks that do not depend on it still run. - Studio: the profile pins the thinking switch, and a reasoning provider brings its timeout.
The knob existed in the crew YAML (
llm.thinking.enabled) and the settings file (Llm:Thinking:Enabled) but had no place in Studio.ModelProfile.ThinkingEnabled/ThinkingEfforttravel asORKEON_Llm__Thinking__{Enabled,Effort}; the profile editor offers provider default / on / off and the effort hint;LlmSectionreads and writes the same keys, the validator types them, and the TUI form has aThinkingfield. Picking Kimi, DeepSeek, Z.AI or MiniMax — whose default model reasons before it answers — pre-fills the timeout withLlmPresets.ReasoningTimeoutSeconds(600 s, the settings templates' value) and the hint says why; a typed value is never overwritten, and the seed leaves with the card that brought it.
Fixed — Studio: the limits tab shows the engine's defaults as watermarks, and its switches take one click (STUDIO-22)
- Every empty field of « Settings › Limits & index » now names the engine's default as a
watermark — the four per-minute budgets and the queue limit (
RateLimitingOptions: 60, 30, 20, 5), the concurrency bound and the log body length as « unlimited » (their engine default is 0, meaning no bound), the RAG profile (fast) and the corrective iteration bound (3). The defaults are copies in Studio Core, pinned against the engine's option classes byEngineDefaultsDriftTests. The watermark is a theme feature: anyInputtext box orComboBoxshows itsTagwhile empty. Owner review 2026-09-20: nothing said what an empty field meant. - A switch bound to an absent key took two clicks — a check box over a nullable boolean starts indeterminate and the first click only turns it to false. The five switches of the tab (the two LLM-logging captures, on by default; the three RAG opt-ins, off by default) are plain booleans resolving an absent key to the engine's default; setting a switch back to its default removes the key. A guard refuses any nullable boolean on a section form.
Added — Studio: Tools and MCP settings tabs; orkeon run connects the MCP servers the settings declare (STUDIO-21)
orkeon runhonours theMCPsection. Until now the section was bound by nobody: the runners built their host without configuration, so an MCP server written in the settings changed nothing.RunnerHostnow callsAddOrkeonMcpwhenMCP:Serversdeclares at least one server andMCP:Enabledis notfalse, and an explicit startup step (McpStartup) connects every server before the crew loads — and before--validatejudges it and--list-toolsprints the manifest. A server that cannot be connected (missing command, silent endpoint, handshake pending after 30 s) costs one error line naming it, on the log and on stderr, and the run goes on; a crew naming one of its tools then fails at load underStrictTools.McpToolProvideralso implementsIDisposableso a host disposed synchronously releases the child processes.ConfigurationKeys.McpSectionspells the section once for the infrastructure, the runners and Studio.- Settings › Tools (both modes). The keys the tools need — the Tavily key of
web_search(ORKEON_TAVILY_API_KEY) and the Brave key ofbrave_search(BRAVE_API_KEY) — on the same rows and the same store as the API keys of the model tab; the catalogue of every tool a run exposes, by family, each tool that needs something saying what (a key above, a key or the connection parameters given at the call by the agent, an expert setting below); and, for the expert, theshell_commandallow-list (Orkeon:Tools:Shell), never written as an empty array. The catalogue lives in Core (ToolCatalog) and a test pins it againstdocs/tools/inventory.md. Owner request 2026-09-20: the settings had no place for a tool key, and nothing listed the tools or what they need. - Settings › MCP (expert). The
MCPsection as cards: the switch and one card per server (identifier, transport, command, arguments, environment, URL), written in place through the newMcpSectionso unknown keys survive, with the row's own problem said the way the validator refuses the save (STUDIO-MCP-ID,-TRANSPORT,-COMMAND,-URL, and-ENV-SECRETat information level for a secret written in clear).SecretRowViewModelis its own file, public, shared by the two key cards; six settings tabs, two new capture stops, fifty strings in five languages.
Changed — Studio: editable fields look editable, multi-line fields are five lines (T-31, T-32)
- A field says «you can type here» (T-31). Every input — text box, password box, combo
box, check box, radio dot — now sits on its own surface (
FieldBrush,FieldLineBrush,FieldShadowBrushin both theme dictionaries: white with a franker line in light, a well deeper than the surface in dark, an inset hairline under the top edge) instead of the sunk paper the read-only wells keep (YAML, raw JSON, journal, code). A read-only text box goes back to the well recipe. Owner design review: nothing distinguished a field from a block of text, and the user could not see where to write. - The API key is masked while it is typed — the two key fields (profile editor, API keys
card) are
PasswordBoxes on the newInputPasswordstyle, bound throughPasswordBinding.Text(WPF keepsPasswordout of bindings on purpose; the attached property carries it both ways, so the field still empties itself once the key is stored). - A multi-line field is five lines high, with a scrollbar from the sixth (T-32). Two
styles,
InputMultilineandInputMultilineMono, onMinLines/MaxLinesrather than a height — the box follows the font and the theme, never grows the card, never pushes the layout. Six fields wear them: the step-1 brief and «what the result must contain», the per-step instruction, the agent editor's «what it does», the trial's sample inputs (mono) and the conversation composer, whose Enter-sends / Shift+Enter-breaks contract is unchanged. The hardMinHeight/MaxHeightthose fields carried are gone with the change.
Changed — Studio: adopting a team ends the wizard at a blank step 1; the saved card is gone (STUDIO-20)
- « Save to my teams » ends the tunnel. On a successful promotion the wizard goes back to a
blank step 1 — the same slate as « Start over »: step-1 folders and policy, projection, name,
profile, schedule, mounts, conversation, notes — and one status line stays: « Team “X” is saved
in My teams » (
Studio.Create.AdoptedLine, five languages). Owner review 2026-09-20: the wizard used to stay parked on step 4 with a « Team saved » card. A refused promotion is unchanged: step 4, the failure card. - The session leaves « Sessions in progress ». The engine writes the session
Promotedbefore it emitspromoted, and the shell re-reads the catalogs onTeamAdopted; the two saved-card buttons that resumed the session right after an adoption (« Modify the team », « Run the trial again ») are gone with the card, so nothing puts it back. « Modify » on the team card (reverse lookup bypromotedTo) stays the way to reopen an adopted team, and the reopened arbitration offersretry. Nothing is deleted on disk. - Deleted with the card:
IsSaved/NotSaved,SavedPath,InstallCommand,ReopenComposeCommand,RetryTrialCommand,ReopenAdoptedAsyncand the auto-retry flag, the card's markup, eightStudio.Create.*strings in five languages. The expert-only scheduling hint (installof thepromotedevent) is no longer displayed: the schedule lives in the sidecar, on the team card and in the team'sschedule/folder.
Changed — Studio: « Allow a folder » opens the OS folder dialog directly; the in-app picker modal is gone (STUDIO-19)
- The « Allow a folder » modal is deleted — path field and Browse, the one-level tree with
its « already allowed » notes, the two rights rows, the expert mount-string preview
(
FolderPickerViewModel, its overlay inMainWindow.xaml, themodale-declarer-dossiercapture stop, elevenStudio.Settings.*strings in five languages). Owner review 2026-09-19: a relic of an earlier design that the Windows folder dialog replaces outright. - Settings › Authorized folders: the novice card's button calls the OS folder dialog
(
IPathPicker.PickFolder,OpenFolderDialog); the pick lands read-only under a virtual name derived from the folder's own name —MountDefinition.SuggestVirtualPath, one derivation in Core for both doors, first free suggestion when the name is taken, reserved or unusable. The card's rights badge is now a toggle, read-only ↔ read-and-write (rwndnarrows to read-only); the novice auto-save hears it like any edit. - The wizard's « Existing folders » door (
CreateTeam.PickFolderRequested): the same OS dialog, opened on the reopened team's folder when there is one; the pick is declared in the settings under the ROW's rights, saved, and bound behind the row — the five steps of STUDIO-14 D-10 unchanged. A cancelled dialog does nothing and says nothing. IDirectoryProbe.ListSubdirectoriesand theLeftIndentconverter leave with their only consumer, the tree.
Fixed — Studio opens on the per-user settings file (STUDIO-18)
- The window loads
%APPDATA%\Orkeon\appsettings.jsonat startup ($XDG_CONFIG_HOME/Orkeon/appsettings.jsonelsewhere) — the fileorkeon initwrites and the last step of the CLI's resolution chain. « Settings › Authorized folders » used to open empty on a file declaring two folders: the editor started on an empty document, and only the expert's Load button ever read the file. The location stays Global (the same file under its own name); a machine without the file starts empty with no message; an unreadable file is reported on the status line and nothing is saved over it (ConfigTabViewModel.InitializeAsync). - A novice edit no longer clobbers the file. The auto-save wrote the empty document plus
the one edited key over a file that also held the
Llmsection, dropping it; the loaded document keeps every key Studio does not model, as the expert's Load/Save cycle always did. - Declaring a folder reaches every verdict at once. The team cards' red chips and the Run screen's refusal were computed on an adoption, a target pick or a restart only; every edit of the declared list now refreshes them, and the cards built before the file was read are rebuilt once it is.
- The screenshot campaign no longer loads the settings file itself (
CaptureShellBuilder.PrepareAsync): the shell does, as the real window does.
Changed — the output cap defaults to the model's documented maximum, not 4096 (LLM-10)
LlmConfig.MaxTokensis a pin, nullable, null by default. Nothing pinned means the request carries the model's documented maximum output from the newOrkeon.Constants.Llm.LlmModelOutputLimitscatalogue (each entry with its vendor page and the date it was read, 2026-09-19): 128 000 ongpt-5.6-sol/gpt-6-astraand the Claude 5 generation, 393 216 ondeepseek-flash, 131 072 onkimi-k3(the vendor's own default), the GLM-5 and Qwen 3.7/3.8 families andMiniMax-M2, 65 536 on Gemini 3.x Flash, 128 000 ongrok-4.6; no cap at all where the vendor documents none (Mistral: prompt plusmax_tokensmay not exceed the window, so the field is left out; Ollama:num_predictis left out, the runtime generates to its context); the window itself on Together, whose provider now sendscontext_length_exceeded_behavior: truncate; and 4096 only for a model the catalogue does not know — the value the engine used to send for every model, which a reasoning model spent thinking and answered empty (owner recette 2026-09-19,kimi-k3: two green runs, one deliverable). A pinned value —Llm:MaxTokens,ORKEON_Llm__MaxTokens, a Studio profile, a crew'smax_tokens— always wins, including an explicit 4096, which the old!= 4096sentinels could not tell from "unset". Resolution lives in one place,LlmConfig.ResolveMaxTokens(provider, defaultModel). Unverified figures stay out: MiniMax-M3 (no vendor figure), HuggingFace's router (per routed provider).- A catalogue cap the endpoint refuses is retried once without the field, with a warning
naming the model (
OpenAICompatibleProviderBase.TryDropCatalogueOutputCap, matched on the field's name in the vendor's own wording); a pinned value's rejection surfaces unchanged.TryAdaptRejectedPayloadnow receives the effective config; Kimi's temperature self-heal defers to it. - Studio's profile editor says what an empty « Maximum response » means for the chosen
model — the documented maximum, no cap, a local runtime, or « not in the catalogue: 4096,
pin it » — and follows the model and provider as they are edited (four keys in the five
resx replace the static hint).
orkeon initwrites noMaxTokens; the example settings templates drop theirs where the model is in the catalogue. - Breaking in the public API:
LlmConfig.MaxTokensisint?,CreateValidated(maxTokens)takesint?(null = unpinned),OllamaRequestOptions.Builder.AddNumPredict(int?), and the YAML exporter writesmaxTokensonly when pinned.
Added — Studio: the Run screen shows the task in progress, pulses while it runs, stamps its journal and starts each launch clean (STUDIO-17)
task.startedjoins the run event stream (orkeon run --events jsonl): every orchestration mode announces a task the moment its agent is chosen, through a newICrewExecutionHook.OnTaskStartedAsync(TaskStartSnapshot: task, agent role, start time) dispatched byCrewHookDispatcherlike the completions. The payload is deliberately thin — nothing has been measured yet. The envelope stays atv: 2: an older client ignores the kind, and a client must not refuse atask.completedit never saw started. The hierarchical mode'stask.completednow carries the agent's role underagentRole, as every other mode does, instead of the agent's GUID.- The Run screen shows what is running now.
RunProgressModelfoldstask.started/task.completedinto an in-flight list andtool.called/tool.returnedinto the tool at work; the card gets a row per running task — agent, turning glyph, « since HH:mm:ss » from the run's own clock — an activity line naming the tool, a summary that says « N finished, M in progress », and the number of tool calls on every finished row. The « running » badge pulses while the child process lives (a Style-scoped storyboard with noTargetName;CapturePoseholds it at full opacity for the screenshot campaign). - Every launch starts clean. The journal and the previous run's verdict — exit badge, result row, « Open the result » — are cleared once per click, so a « validate first » launch keeps both passes in one journal. « Clear the journal » stays; « Copy » is how a journal survives.
- The technical journal is timestamped. Each line shows the local time Studio read it, and the copied text leads every line with it. The CLI's own log lines keep their millisecond prefix.
- The engine names the cause of an empty first turn. The warning « produced empty final
message after N iterations » now carries the request's
max_tokensand says that the tool-free retry cannot call tools and that a reasoning model needsLlm:MaxTokens16384 or more. Owner recette 2026-09-19: akimi-k3profile withoutMaxTokens(engine default 4096) spent the budget reasoning, answered empty, and the tool-free retry narrated the.docxinstead of writing it — a green run with no file. The remedy is the profile's Max tokens field; the journal now says so at the moment it happens.
Changed — the DeepSeek default follows the vendor's rename, vendor prices refreshed, catalogues reviewed (2026-09-19)
LlmProviderDefaultModels.DeepSeekisdeepseek-flash. DeepSeek retired V4 Flash and V4 Flash Vision Exp on 2026-09-10 with the V4.1 Flash release:deepseek-v4-flashanddeepseek-v4-flash-vision-expare legacy names the API "temporarily" routes to V4.1 Flash, anddeepseek-flashis the vendor's own id for the Flash tier — 1M context, 384K output, thinking on by default, native vision. Every DeepSeek configuration that names no model follows; the campaign kit, theorkeon inittemplate, the Studio DeepSeek card, the examples and the guides name the new id, and the kit'svisionModelcompanion is gone (the default sees). The 2026-09-07 campaign measured V4 Flash under the old name and is to be replayed. This is the only default that changes: every other default is served today, and the newer models found are recorded as candidates pending a campaign — the Mistral lesson.ModelPricingRegistryfollows the vendors' pricing pages (read 2026-09-19). OpenAI:gpt-5.6-sol4 / 20 $/M (promotional through 2026-11-21; was 5 / 30),gpt-5.6-terra2 / 12,gpt-5.6-luna0.20 / 1.20,gpt-6-astra10 / 50 added (its 2× billing above 272K input tokens is not expressible in a flat registry). Anthropic:claude-sonnet-52 / 10 (the rise to 3 / 15 was cancelled),claude-fable-5-1andclaude-fable-510 / 50,claude-haiku-4-51 / 5 added.- Catalogue review of the 16 providers.
docs/reference/llm-providers-comparison.mdgains a "Defaults and newer models" section (EN/FR) and theLlmProviderDefaultModelscomments carry the dated findings:gpt-6-astra(nononeeffort, so the function-tools workaround cannot follow it),claude-fable-5-1,gemini-3.8-flash(GA 2026-09-02, same price until 2026-12-31), theglm-5.3family (thinking cannot be disabled),qwen3.8-max/qwen3.8-flash(noqwen3.8-plus),kimi-k3(fixed sampling, always reasons),MiniMax-M3, Together'szai-org/GLM-5.3-Flash, HuggingFace'sQwen/Qwen3.5-9B(the current default has tool support on one routed provider out of four), Ollama's small tools + thinking models. Nothing new for chat at Mistral and xAI.
Added — team folders travel with the team, and the forge trial reads where the documents are (STUDIO-14, lots 0 and 1)
orkeon forge … --read <dir>(new session andresume): the trial mounts<dir>as/workspace, read-only, in place of the working directory — which keeps every other role: the session still lives under its.orkeon/forge/<slug>/, the settings still resolve next to it. A folder that does not exist is refused before any session is created (--read names no directory, exit 1);promoterefuses the option, since it mounts nothing. A read folder outside the process working directory is whitelisted for the file tools the wayorkeon runwhitelists its script directory — the forge's mounts are its own three roots. This is the engine hook Orkeon Studio's wizard uses to try a team on the folder chosen at its first step.- Studio's team sidecar (
studio-team.json) records a team's own folders relative to the team: a physical segment starting with./(./input:/workspace:ro,./output:/output:rw) is a folder inside the team folder, so a team copied, exported or moved keeps writing into its own folder without a rebase step.TeamCatalogresolves the entries once, at its boundary (Describe,DescribeTargetandListreturn absolute paths; the raw entries stay onMetadata.Mounts), relativizes on every save (SaveMetadata,SaveMounts) and creates each relative folder there — the single point where an in-team folder is materialised. Duplicate, export and import copy the entries verbatim and rewrite an older absolute in-team path relative on the way;TeamCatalog.RebaseMountsis removed.DeclaredMounts.IsInsideTeamis public and vouches for a./entry before the team folder exists;MountValidator.Validatetakes an optionalteamDirectoryand skips the existence check of a relative entry until one is known.TeamMountPaths(Studio.Core) is the one helper for the convention, andFolderPolicynames the step-1 choice the wizard asks (Later / InsideTeam / ExistingFolders — the wizard entry below).
Fixed — the forge trial no longer refuses a settings file that names /output or /workspace (STUDIO-14, owner recette)
orkeon forgerefused any settings file whoseOrkeon:FileSystem:Mountsnamed/workspace,/forgeor/output(ERROR: '/output' is a virtual root reserved by the runner…, exit 1). That guard predates STUDIO-15: since a--mountis placed by virtual root, the forge's own three mounts replace such an entry for the trial, and theDuplicate virtual pathscrash the guard pre-empted cannot happen. Studio's wizard hit it on its first real team — the folder associated at step 1 is declared in the allowed folders as/output, the Studio convention for a team's write folder, and the trial refused to start. The forge now reserves/sandboxalone (the one root it does not mount itself); a settings entry on/workspace,/forgeor/outputis replaced for the trial and logged asmount /output: --mount replaces the settings entry, a line the forge now lets through to stderr.RunnerVirtualRoots.ForgeReservedis removed.
Added — Studio: Settings › Authorized folders lists each team's own folders, read-only (STUDIO-14, lot 6)
- A team's own folders (
./input,./output…) are vouched for by living inside the team and are never written to the globalOrkeon:FileSystem:Mounts— that would duplicate the sidecar in a file every team shares and put one team's private folders in the list every other team picks from. The settings screen now shows them all the same: the folders tab ends, in both modes, with a read-only « Team folders » section (TeamFoldersViewModel,SettingsScreenViewModel.TeamFolders) — one line per in-team folder of each adopted team, « Veille concurrentielle · /output → output » with the one-word rights badge, read from the sidecars (team-relative entries, and the absolute-under-the-team spelling of an older sidecar), never a disk path. No command, nothing written; the hint says these folders are changed from « My teams ». The section follows every change to the team list (adoption, import, deletion, duplication, the folders modal) and re-reads the sidecars when the tab opens. KeysStudio.Settings.TeamFolders{Title,Intro,Empty,Row}in the five cultures; the capture seed givesrapport-hebdoan in-team./output:/output:rw, photographed byreglages-dossiers.
Added — Studio wizard: where the folders live, asked at step 1; « Create inside the team »; the trial reads the chosen folder; « Open the folder » (STUDIO-14, lots 2–5 and 8)
- Step 1 « Describe » gains a fourth question, « Where are your folders? », with three chips
and no obligation — composing never waits for it. « Existing folders » shows the two rows a
team can address before it has a blueprint (« Your documents »
/workspaceread, « The results »/outputwritten); « Choose the folder… » on either opens the disk picker on the row's rights (read-and-write for the results), and the pick is declared in Settings › Authorized folders unless already held, saved, then bound behind the row under the row's rights — one gesture, the status line says which happened, a refused save still binds. A folder picked inside the reopened team is bound and never declared. « Created inside the team » answers both rows team-relative on the spot (./input:/workspace:ro,./output:/output:rw), read « inside the team: input / output », nothing created before the adoption. « Later » behaves as before. The chips move the two canonical roots only; composing keeps them; « Restart », a resume and « Modify » forget them, policy included. - The two rows are a start, not a limit (owner review of 2026-09-19): « Add the folder » names
as many further mount points as the need calls for — a name the agents will use, read or
written — each a row answered the same two ways (« Created inside the team » answers a new
one on the spot), shown on the Composer step next to the blueprint's roots, kept across
compositions, created inside the team at adoption when left unanswered; a folder added at
step 1 through the picker counts as one of them. A reserved or already-used name cannot be
added. Keys
Studio.Create.AddRoot,AddRootWrite,AddRootHintin the five languages; theetape1-dossiers-existantsstop photographs a third, user-named row. - Step 2 « Compose » re-shows the step-1 answers, editable as before, and gains « Create every
folder inside the team » beside « Allow a folder » plus a per-row « Create inside the
team ». Under the inside-the-team policy a root a later blueprint adds is answered the same
way as it appears, unless dropped. A row inside the team shows a label, never a disk path,
and never reads red:
DeclaredMounts.IsVouchedFor(declared, or the team's own) is now the rule of the wizard's rows and of the "My teams" cards, as it already was of the launcher — a team's own/outputno longer reads red on its card. Theinput/warning stays for a read root answered inside the team: thatinput/is created just as empty. - The adoption records team-relative entries for every in-team answer and every root the
blueprint addresses that nothing answered (
CreateTeamViewModel.SidecarMounts(), replacingWithDerivedWriteMounts(teamDirectory)— no team directory needed any more; the save creates the folders). « Modify » on a card seeds the rows from the sidecar's own spelling, so./outputreads « inside the team » and is written back as it is. - The trial reads where the documents are: every engine invocation of the wizard carries
--read <dir>(ForgeStartRequest.ReadDirectory,ForgeArgumentsBuilder) with the folder bound behind/workspace— a real folder as it is, a reopened team's owninput/resolved under the team — and nothing before the team exists, where the argv is unchanged and step 3 says the trial runs on an empty folder. An engine that predates--readmeets it only when a folder is known, and then refuses it on the failure card (STUDIO-13). - The Run screen turns
--allow-external-mountson by itself when a team folder is a real folder outside the team, and off when every team folder resolves under it — the launch's working directory needs no flag (STUDIO-12 C4, Studio half); the expert checkbox stays for the per-launch mounts. - « Open the folder » in the wizard's header, in both modes at every step: the working
session — which holds the generated
crew/— before the adoption, the adopted (or reopened) team afterwards, the tooltip says which; present whenever a shell opener is wired, like the team cards' button (CreateTeamDependencies.ShellOpener). FolderPickerViewModel.Opentakes aninitialRights; three capture stops (etape1-dossiers-existants,etape1-dossiers-equipe,etape2-dossiers-dans-equipe); fourteen newStudio.Create.*strings in the five languages.
Fixed — Studio: team cards bounded, the rights badge in one word, the closed rights list says its label (STUDIO-16)
- A team created from a long brief — a README pasted into the name field — filled the
Run screen's team card and the "My teams" card with the whole page: a WPF
TextBlockrenders line breaks even without wrapping and has noMaxLines. The name is now one line everywhere it is displayed (run card, team card and its tooltip, launch history) throughTeamCatalog.NormalizeName— first line, Markdown stripped, cut at a word under 64 characters, the slug's cap, never empty — and adoption writes that normal form into the sidecar for every name, typed or proposed; the "Team name" field shows it live; import normalizes the name of the sidecar it copies.ShortName(48 characters, applied only to a proposed title) is gone, replaced by the Core rule. - The Run screen's meta line appended the whole
descriptionafter the agent count and the folders — the actual text of the owner's capture, a README in the muted 12 px of the meta line, not the bold headline — and the "My teams" card showed it unbounded. Both now readTeamSummary.Summary/TargetDescription.Summary: the first paragraph without markup, cut at a word under 240 characters (TeamCatalog.Summarize), derived at read time and never stored — the sidecar keeps the whole need. On "My teams" the block is folded to three lines with a "Show more" / "Show less" link shown only when the folded card hides something; unfolded, it shows the whole need. - In Settings › Authorized folders, the rights badge of a mount row carried the full
52-character label and pushed the virtual name out of the list; the selected
writable row was seven lines tall with the badge floating in its middle — the badge
measured first took the whole row, the name was measured at width zero and, wrapping
(
MonoText), broke/outputinto one character per line. The row now puts the name first, never shrunk, and the badge last in one word (MountEditorViewModel.RightsBadge,MountRightsTokens.GetBadge, keysStudio.Settings.RightsBadgeRo/Rw/Rwndin the five cultures) with the full label as its tooltip. - The three
DisplayMemberPathdrop-downs (the two rights lists, the trial screen's team picker) showedMountRightsChoice { Rights = ReadWrite, … }once closed: the globalComboBoxtemplate boundSelectionBoxItemTemplatebut not the item template selectorDisplayMemberPathgoes through. The template now bindsContentTemplateSelectorandContentStringFormatlike the stock one; a XAML guard (ComboBoxTemplateConformityTests) pins the four bindings. - The capture seed gains a team whose sidecar is a pasted README (multi-line name,
forty-line need), photographed by
executer-carte-equipeandequipes-liste;reglages-dossiers-selectionnow selects the writable mount.
Added — OpenRouter and Mammouth AI, the fifteenth and sixteenth providers — two aggregators, the one exception to the scope freeze (LLM-09)
The scope freeze said "no 15th provider — point Orkeon:Llm:BaseUrl at the endpoint", and
that fallback works today for both. What it cannot do is what the owner lifted the freeze for
on 2026-09-18: declare capabilities (the fallback inherits OpenAI's, so an option the
aggregator ignores is a silent drop), read what the aggregator writes, and be recognised by
Studio, orkeon doctor and the campaign kit. Both arrive documentation-first, the MiniMax
way: every declaration is sourced from the vendor's documentation and from cold calls dated
2026-09-18, marked campaign-pending in the code, the comparison table (two † rows) and the
campaign catalogue — the compiled defaults are claims until a live M1 is archived.
OpenRouterLlmProvider (openrouter.ai/api/v1, key openrouter, OPENROUTER_API_KEY,
default google/gemini-3.7-flash — the fleet's Gemini default under the marketplace's
mandatory vendor/model id, so a red behind the aggregator with a green in direct is a fact
about the transport; openrouter/auto refused as a default because it drifts) declares
JsonSchema / Budget / vision, reads its reasoning field, writes the thinking controls
as the reasoning request object (enabled / effort / max_tokens, the base's
thinking block and first-level reasoning_effort removed so one intention is not sent
twice), exposes usage.cost and its breakdown (upstream_inference_cost, is_byok,
cache_write_tokens, reasoning_tokens) and the served model as served_model, and
sends the two constant attribution headers HTTP-Referer (spelled as the vendor documents
it) and X-OpenRouter-Title. MammouthLlmProvider (api.mammouth.ai/v1, key mammouth,
MAMMOUTH_API_KEY, default gemini-3.7-flash — the same model under the bare id the
proxy serves) declares nothing the vendor does not document (response_format and thinking
stay None with the structured warning, vision follows the vendor's text, image list) and
overrides no behaviour. Routing is by host or explicit key for both, plus the unambiguous
openrouter/ model prefix; a vendor/model id alone is not an OpenRouter claim (HuggingFace
and Together use the shape), a bare Mammouth id is the vendor's own string, and the
pre-existing vendor-prefix inference that ignores the / is pinned as is — eight routing
rows, negative pins included. The sk-or-v1- key prefix is documented by secondary sources
only, so the factory does not infer from it until the first key confirms it. Full fleet
integration otherwise: satellite constants and both PublicAPI.Unshipped.txt, factory,
llm.openrouter / llm.mammouth in the scripting DSL (with Orkeon:DefaultLlmProvider
resolving their own models rather than the platform default), doctor, orkeon llm probe|models, Studio cards and detection in five locales, the campaign catalogue with the
questions each first campaign must settle, example settings, and the counts 14 → 16 under
the claims gate. The scope freeze is rewritten in CONTRIBUTING.md, CONTRIBUTING.fr.md
and docs/reference/limitations.md (EN/FR): sixteen providers, two of them aggregators, no
17th — the rule stands for everything else.
Changed — the OpenAI-compatible base reads what an aggregator writes, and never ends a failed stream cleanly (LLM-09)
Three changes on OpenAICompatibleProviderBase, all generic. The reasoning field is a
dialect hook: ReasoningFieldName (default reasoning_content) names the field the buffered
parser and the stream accumulator read the trace from, and an overload
ExtractReasoningContent(JsonElement, Builder, string fieldName) joins the frozen
two-argument one, which delegates to it — DeepSeek, Z.AI and MiniMax parse exactly as before,
their tests untouched; the Orkeon metadata key stays reasoning_content whatever the vendor
calls the field. usage.cost becomes the cost metadata (double) on the buffered path and
from the final usage chunk of a stream — exposed, not accounted for: CostBudgetManager
keeps estimating from the pricing registry. And a chunk carrying a root-level error after
the HTTP 200 — OpenRouter's documented mid-stream failure shape, and the shape any compatible
vendor writes — used to be skipped as "no choices": the chat stream completed cleanly with
truncated content, and the token stream ended normally. Each path now ends the way its
pre-stream refusal does: ChatStreamingAsync completes with the error / error_type
metadata over the content received so far (no reassembled tool calls), GenerateStreamingAsync
throws the HttpRequestException StreamingRejectionAsync throws, carrying the vendor's code
as the status when it is an HTTP one. Secrets in the vendor's message are redacted; a
"error": null on a healthy chunk is not an error.
Changed — a --mount is placed by virtual root against the settings (STUDIO-15, STUDIO-12 C3/C4)
RunnerHostplaces every--mountby virtual root. On a root the declaredOrkeon:FileSystem:Mountsarray (settings file andORKEON_environment) already holds, the--mountis written at that entry's index and replaces it for the run — logged asmount /x: --mount replaces the settings entry; on a new root it is appended after the highest declared index. A settings entry and a--mountnaming the same root used to reachFileSystemRegistryas two mounts and fail every such run at kickoff withDuplicate virtual pathsout of a DI factory — which is exactly what Studio's team flow produced by design, since a team associates a folder the settings already declare, verbatim.InternalMountskeep appending. Only a case that failed changes outcome.RunnerExecution.EnsureVirtualRootsAreUnique(public, wired in the YAML runner'sTryBuildHostand inorkeon run's script path): two--mounton one root, or a settings file declaring one root twice, exit 1 with one actionable line (ERROR: '/x' is mounted twice on the command line: <a> and <b>. Keep one./… declared twice in <settings>) before any host is built.Using settings: …is now printed once every mount guard has passed, so a refusal never reads as a run that started.- A DI-factory failure at kickoff ends stderr with
ERROR: <message>as the last line (the sentence exit code 2 promises); theCrew execution failedlog entry carries the message only, and the exception with its stack trace moves to aDebugentry (--verbose 2, orORKEON_DEBUG=1on stderr). - A mount declared in the settings is always whitelisted for
PathValidator(STUDIO-12 C4): its base path joinsPathSecurity:AdditionalAllowedDirectorieswithout any flag — a declared folder is the machine owner's explicit intent. Until now such a folder outside the process working directory was mounted and every access refused as "outside the allowed workspace directory", and--allow-external-mountscould not rescue it since it only whitelists--mountarguments — which it still does, and only that. - Studio predicts the by-root rule (
MountOverrideSemantics.ComputeEffectiveMounts, theStudio.Settings.Explanation/ThisLaunch/EffectiveReplacedstrings in the five languages): the effective-mounts table shows one row per root, the settings entries in place,--mount (replaces «…»)on a replacement, and the runner's own/crewmount appended after the declared entries — instead of the index-0 masking that never happened. An empty--mountentry is reported against the--mountoption rather than a configuration key the arguments alone cannot know. - Studio never lays a settings entry twice (
LaunchMountPlan.WithoutSettingsDuplicates): a team folder the settings already hold — same folder, same name, same rights — stays off the command line; the same folder under another name, or with other rights, is laid and shown as the replacement it is. The Run screen's settings-mounts list now follows the settings the run will find: the pinned--settingsfile, or « Settings › Authorized folders » in automatic mode.
Added — Studio: a failed « Composer l'équipe » is said, with the technical part copyable (STUDIO-13)
In novice mode the creation wizard kept its promise of a screen without machinery right up to
the first failure, where it showed nothing at all: no orkeon binary, an appsettings.json
the engine refuses, a non-zero exit — a click, a spinner, then step 1 again, with the reason
on a grey one-line status (truncated, untranslated) or nowhere, and the whole stderr in a
technical journal only the expert mode displays.
CreateTeamViewModelcarries one failure model,WizardFailure(Kind,Headline,Detail,CommandLine,ExitCode,Stderr,EngineError,Journal), exposed asFailure/HasFailure, withBuildFailureReport(),CanCopyFailureReportandFailureReportCopiedon the diagnostic screen's pattern. Five families, classified on the run outcome, the engine'serrorevent and the exit code:EngineMissing(the locator found no binary —NotStarted),ConfigRefused(anerrorevent withrecoverable: false, aFORGE-*code),EngineStopped(non-zero exit, with or without stderr — and a session that reportedfailed),Unknown(an exception out of the launch, which used to reach aMessageBoxat best —RunEngineAsyncnow catches it, cancellation excepted) andPromoteRefused(step 4, nopromotedevent). « Arrêter » never produces one.- The report is the
orkeon forgecommand line, the exit code, the engine's code and message, the detail the card shows and the whole journal — the WHOLE stderr, where the status line only ever kept the last line. CreateTeamViewshows the card under the status line in both modes, bordered in the danger colour on the assistant gate card's pattern: the novice sentence of the family (localized, five cultures), the engine's own text in mono (wrapped, bounded, scrollable, never translated), and « Copier le rapport » (« Copié ! » for 1.6 s), « Réessayer » (the compose at step 1, the save at step 4) and, by family, « Ouvrir le diagnostic » (the doctor re-runs on arrival) or « Ouvrir les réglages ». It clears on the next composition and at the start of every run; the status line, the expert journal and the faultMessageBoxof the other commands are untouched.Studio.Create.StatusFailednow says where the detail is («the detail is in the card below»), true in both modes.- Capture campaign: a stop
etape1-echec-moteur(both modes, both themes, language sweep) photographs the card by taking the CLI away from the seeded machine and clicking — the owner's own recipe, through the real locator; with the three STUDIO-14 stops the catalogue is pinned at 52 stops, 282 shots. - Tests:
A_missing_engine_is_said_on_step_1_with_a_copyable_report,A_non_zero_exit_without_stderr_still_shows_a_failure_card,An_unrecoverable_engine_error_names_its_code_and_message,The_failure_report_carries_the_command_line_the_exit_code_and_the_whole_stderr,A_new_compose_clears_the_previous_failure,A_refused_promotion_uses_the_same_failure_card,An_exception_during_the_launch_becomes_a_failure_card_rather_than_a_fault,Stopping_the_engine_raises_no_failure_card.
Fixed — the team journey on a real crew: order, empty answers, single-file teams (STUDIO-12)
- Task order follows
dependencies, in every layout. Without a plan, the sequential strategy — and the hierarchical, consensual, graph and autonomous modes, which hand their tasks out one after another — now runs the crew's tasks in a stable topological order on their declared dependencies (TaskExecutionOrderin the Domain,CrewTaskSequencerin Infrastructure): a task runs after every task it depends on, and the declared order is kept wherever the dependencies allow it. The multi-file layout lists tasks in the ordinal order of their file names, soconsolidate.yamlran before theextract.yamlit depends on — silently, with a green run. An unknown dependency is ignored; a cycle keeps the declared order for the tasks caught in it and logs a warning. The orchestrator no longer synthesises a "plan" fromcrew.Taskswhen planning is off. - An empty final answer is never a green run. A model that answers with empty text —
and empty text again on the tool-free retry — exits
AgentExitReason.EmptyFinalAnswer(new member) in the three agent loops, including on the very first turn, which used to pass asCompleted; the task fails with the reason inTaskResult.Error(a failed exit used to leave it null), a sequential crew with a failed task reports failure with the reason (CrewOutput.Error, new;CrewHookStatus.Failed→ AUTO_SUMMARY.md and theerrorrun event), and the one-shot runner exits 2 withERROR: <reason>as its last stderr line — it returned 0 for every crew failure, sorun.finishedsaidsuccess:trueover an empty output mount. The reason namesLlm:MaxTokens, the setting that usually explains it for a reasoning model. - Studio:
MaxTokenson the model profile.ModelProfile.MaxTokens(null = the engine default, 4096), emitted asORKEON_Llm__MaxTokens, round-tripped by the store and editable in the profile editor next to temperature and timeout, with a hint that a reasoning model needs 16384 or more. - Studio: the profile store reads case-insensitively and says when a file is unreadable.
A hand-written camelCase
studio-model-profiles.jsonloaded zero profiles, silently; a file that fails to parse was indistinguishable from an empty one.LoadAsyncnow returns aModelProfileLoadResultwhoseErrorthe settings screen shows. - Studio: a folder holding a single-file crew is a team. The target detector resolves
a folder with no layout marker and no script but one
*.yaml/*.ymlfile as that file (RunTargetKind.SingleFileCrewDirectory: run path = the file, selected path = the folder, at the root or under the promotedcrew/nesting); several YAML files resolve tocrew.yamlthenconfig.yaml, else are offered as candidates.TeamCatalog.Importruns the detector before copying and refuses, with the detector's message, a source it cannot resolve — it used to copy any folder verbatim into a team nothing could run.
Changed — the scripting runtime runs its loops in JavaScript (SCR-25)
A Jint engine has one event loop and one drainer at a time, and the runtime kept
re-entering it from the wrong side: C# loops that invoked script functions after an
await, or blocked on a promise from inside one of the engine's own jobs. Measured through
ScriptHost against a provider that really suspends (Task.Delay(3), the shape of every
HTTP provider): a crew run placed after any top-level await hung — a file read or a tool
call before crew.run() was enough, and the second of two sequential runs hung too; a state
graph whose nodes suspend timed out at 10 s from a body, and hung outright when run()
followed an await; five concurrent ctx.state.with crashed the engine or timed out; a
topic handler that awaits so much as a microtask timed out when publish() came after the
body's first await — and was delivered when it came before; FSM onEntry/onExit that
await the model timed out; an async onAgentStart reached through ctx.spawn timed out; an
async onCrewStart never returned; act's onDelta ran on a pool thread while the body was
drained on another; and for await over runStream threw The value is not iterable. The
fifteen reproducers of EngineThreadingContractTests pin the family; all of them run.
The fix is one rule, applied everywhere: every loop that calls back into script code lives
in JavaScript. crew.run/runAgent/runStream, stateGraph.run/runStream,
stateMachine.send, ctx.state.with, topic.publish, the event's lock and ctx.llm.act
are async functions (generators for the streams) built from a JavaScript factory; the CLR
hands them synchronous helpers and Tasks to await, and never re-enters the engine from a
continuation. A helper that fails throws in JavaScript (JsHostError: an Error carrying
the CLR exception on clr), so the script's catch/finally run and the typed exception
comes back out. The CLR drives the engine at three root pumps only — ScriptHost for the
script, JsCrew.RunAsync for the globalThis.crew handoff, JsTool.CallAsync for a script
tool the orchestrator calls — each on an engine at rest, under the per-engine gate, one
thread for the whole evaluation, bounded by its cancellation token: the three 30-minute
promise ceilings (BodyPromiseTimeout, ToolPromiseTimeout, LoadPromiseTimeout) are gone.
The model is written up in docs/architecture/scripting.md (EN + FR).
Breaking for C# callers. JsCrew.run, runAgent and runStream, JsStateGraph.run
and runStream, JsStateMachine.send, JsAgentContext.stateWith, JsEventTopic.publish,
JsPublishedEvent.lock and JsLlmFacade.act are JsValue properties now — the JS functions
themselves, not CLR methods. A host runs a crew through JsCrew.RunAsync(options, ct); the
script surface is unchanged, and so are the typings but for one correction: onCrewError's
second argument is declared string — the displayed message the runtime has always passed —
where crew.d.ts promised an Error no script could read .message off. What one loop
doing the work of several changes for scripts:
- An
onCrewStartthat throws fails the run and reachesonCrewError(it ran outside the error path before); a run started on an already-cancelledsignalskipsonCrewStart, andonCrewErrorstill runs (a C# caller with a cancelled token getsOperationCanceledExceptionbefore any hook). AnonCrewErrorthat throws itself is logged and the original error is what the caller receives — it used to replace it. - Cancellation bypasses
onError: a cancelled run rethrows without consulting the policy (it used to consult it with codeunknown, then throw at the next step anyway). Anderr.codefor a failure that arrived as a faultedTaskmaps from the innermost CLR exception (receive_timeoutfor actx.receive({ timeout })that expired, …) instead ofunknownfor the wrapper. - An async
onAgentStart/onAgentStopthat rejects is logged as a warning; it no longer throws out ofcrew.add,ctx.spawnorcrew.remove, which stay synchronous and never drain the hook. crew.runAgentruns its target under the instance semaphore, with anAgentContextand the agent's ownonErrorpolicy; it accepts a name or anAgent, honoursopts(signal,timeout), and throwsRecursiveAgentInvocationExceptionwhen called on the agent whose body is executing instead of deadlocking on the semaphore that body holds. It used to invokebody(input, undefined)bare.- A run opened from a body —
crew.runAgent,await sub.run()on another crew — without{ signal: ctx.signal }is a child of the run that opened it: cancelling the outer run cancels it, and its semaphores come back inside the outer run's own unwind. It used to run on with no token of its own, and a sub-crew whose body never settled kept its semaphore for good. The link is best-effort under runs interleaved on one event loop (it reads the most recently opened attempt still open); the explicitsignalremains the exact form. And a cancellation the loop meets on a Task it awaits itself — the instance semaphore, a retry delay — rejects the script with the same bridgedOperationCanceledException(clrType,message) as a raced body await, not Jint's raw cancelled-task object. crew.runStreamis the run, observed as a stream: same semaphore, contexts, hooks and spans asrun;atis epoch milliseconds (Date.now()) instead of .NET ticks;breakout of thefor awaitends the run without firingonCrewCompleteoronCrewError.ExecutionTimeoutandMemoryLimitBytes(Orkeon:Scripting:Limits, 30 s and 100 MB by default) span a whole CLR-driven run — theglobalThis.crewhandoff throughScriptHost.RunAsync,JsCrew.RunAsync— as they already spanned a whole script; both used to re-arm per agent body (Jint resets its constraints at each non-nested entry, and the run is one entry now). A host driving a longer run, or a many-agent one that allocates, raises the limits, as a long script already required.ScriptHost.RunAsyncandJsTool.CallAsyncpropagate cancellation asOperationCanceledException— a 30-minute wait that ignored the token, and a failedToolCallResponse, before.
Fixed — the quickstart's first CI run failed, and three things came out of it
The first run of quickstart.yml on a GitHub runner did everything the README says —
pinned Ollama, the tool installed from the checkout, the settings found next to the crew —
and wrote no file: llama3.2:1b under Ollama 0.34.0 answered the file_write call as
text, the whole JSON envelope with a broken string inside ("content": "create_backup": "False"), the loop took that text for the final answer, and the run "succeeded" with the
envelope as its deliverable. Reproduced here byte for byte with the same Ollama build (four
runs out of four, 550 tokens each; under 0.12.3 the same model makes a real tool call — the
server's template and parser changed between the two).
- A JSON envelope written as text is a tool call.
ToolCallTextParsergains the third shape next to[TOOL_CALL]and<invoke>:{"name", "parameters"|"arguments"},{"type":"function","function":{…}},{"type":"function","function":"<name>", …}, atool_callsarray, fenced or not, with raw line breaks inside strings repaired before parsing. - An answer shaped like a tool call that cannot be executed is not a final answer.
The loop hands it back with the fix the model needs (call the tool, or write one valid
JSON object), twice at most, then lets it stand —
LooksLikeToolCallAttemptrequires the envelope vocabulary and one of the agent's tool names, so a JSON deliverable is never mistaken for one. - The quickstart model is
qwen2.5:1.5b. Smaller (986 MB against 1.3 GB) and it calls the tool the way a tool is called: measured 5/5 under Ollama 0.34.0 and 3/3 under 0.12.3, before and after the two guards above;llama3.2:1bstill fails 3/3 after them (it repeats the same broken string). README, quickstart, Aspire example and workflow cache key follow.
Added — Orkeon.Hosting.Aspire, and runners that export OpenTelemetry by the standard contract (ADR-011)
The runners built their host with the parameterless AddOrkeonInfrastructure() (no
telemetry), never started it (OpenTelemetry's hosted service never created the
providers) and ignored OTEL_EXPORTER_OTLP_ENDPOINT: a run launched from .NET Aspire
produced spans that went nowhere. RunnerHost now registers telemetry from the
settings, honours the standard OTLP environment for traces, metrics and logs, and
resolves the providers itself after the build. Measured with a two-line OTLP sink and
the quickstart crew: v1/traces with invoke_agent Scribe, chat llama3.2:1b and
execute_tool file_write (gen_ai.* attributes), v1/metrics, and v1/logs at
verbosity 1 — with nothing but the environment variable set.
Orkeon.Hosting.Aspire (ninth id of the lineup, PUB-25 wrapper on Orkeon +
Aspire.Hosting 13.5) adds AddOrkeonHost (the orkeon-host daemon) and
AddOrkeonCrewRun (one orkeon run, with its /output mount created by the AppHost)
as executable resources, WithOrkeonSetting / WithOrkeonModel for the ORKEON_
environment, and .WithOtlpExporter() applied. Three launch-free tests evaluate the
arguments and environment Aspire would hand the process (the OTLP endpoint included);
examples/aspire/AppHost/ ran the quickstart crew as a resource on this machine —
out/hello.md written, dashboard up. The decision that goes with it: the Aspire
dashboard is the cross-platform observability surface, and no web Studio will be built.
Added — Orkeon.Interop.AgentFramework: Microsoft Agent Framework, in both directions (ADR-010)
A developer with Microsoft Agent Framework code can try Orkeon without giving anything up,
and an Orkeon crew can be one more agent in a MAF workflow. One package, depending on
Orkeon and Microsoft.Agents.AI.Abstractions 1.20 only, eighth id of the NuGet lineup:
CrewAgent : AIAgent (a crew as a MAF agent — one kickoff per RunAsync, the conversation
as initial context, the final output as the assistant message, token telemetry as
Usage, stateless sessions that still serialise), AIAgentLlmProvider : ILlmProvider
(a MAF agent as the model of an Orkeon agent — AgentBuilder.WithAgentFrameworkAgent, one
MAF session for the provider's lifetime) and AIAgentTool (a MAF agent as a tool of an
Orkeon agent — WithAgentFrameworkTool, the mirror of MAF's AsAIFunction()).
AddOrkeonAgentFramework() registers an ICrewAgentFactory. Eleven tests with
hand-written doubles; examples/interop/agent-framework/ runs both directions on the
configured model — verified on llama3.2:1b via Ollama.
Fixed — the initial context of a kickoff reaches the agents
CrewInput.InitialContext was mapped into the domain input and read by nothing:
orkeon run --initial-context, Studio's field and every programmatic
CrewInput.Empty("…") reached no prompt — a crew asked to "summarise the text given as
initial context" answered "what text?". The orchestrator now exposes it as the
initial_context prompt variable (listed under Context Variables and available to
{initial_context} templates); a caller-supplied variable of that name wins.
Changed — a run now produces spans, and they speak the OpenTelemetry GenAI conventions
The telemetry plumbing was complete and unused: TracingInstrumentation, OrkeonMetrics
and the seven ActivitySources were called by one IChatClient decorator that only the
tests instantiated, so a real crew execution emitted no span at all — and the attribute
names were Orkeon's own (orkeon.llm.model), which no backend recognises. The
ActivitySources move to the Application layer (OrkeonActivitySources, same names;
Infrastructure's OrkeonDiagnostics re-exposes the same instances) and the execution
path emits on them: invoke_agent {agent} around each agent turn, chat {model}
(kind Client) around each model call with the request/response/usage attributes,
execute_tool {tool} around each tool call with the model's own call id — children of
the agent span, error.type and an error status on failure. The names and attributes
are the generative-AI semantic conventions,
declared once in Orkeon.Constants.Llm.GenAiAttributes (ADR-009 satellite) and used by
the loop, the tracing helpers and the scripting runtime alike. OrkeonDiagnosticTags
keeps its member names but its LLM/agent/tool/error values are now the convention's
(gen_ai.provider.name, gen_ai.request.model, gen_ai.usage.input_tokens…);
OrkeonMetrics records gen_ai.client.token.usage (per token type) and
gen_ai.client.operation.duration (seconds) instead of orkeon.llm.tokens / orkeon.llm.duration.
The Terminal.Gui tool aggregator identifies tool spans by gen_ai.operation.name rather
than by a span name. Documented in opt-in-subsystems.md (EN + FR).
Added — a Contributor License Agreement template, and the check that asks for it
Zero external contributors, zero Signed-off-by, and the intent to hand the project to a
foundation one day: the only cheap moment to put a licence frame in place is before the
first outside pull request. CLA.md / CLA.fr.md are an individual CLA template,
marked on their first line as awaiting counsel's validation — the licence is granted to
the current maintainer and any successor entity, so the transfer needs no second round
of signatures. cla.yml (contributor-assistant/github-action, pinned by SHA,
signatures on this repository's cla-signatures branch, no external service, no PAT)
posts the one sentence to reply with and stays red until it is posted; the maintainer
account and dependabot are allowlisted. CONTRIBUTING (both languages), the pull-request
template and the docs-parity gate know about it.
Changed — .NET 11 readiness, without moving the target
ci.yml gains a non-blocking job that restores, builds and tests Orkeon.sln with the
.NET 11 SDK (preview channel until GA on 2026-11-10), widening global.json's
roll-forward on the runner only. Verified locally with 11.0.100-rc.1 — and that build
was red: the 11 SDK ships three analyzer rules the 10 SDK does not, all fixed so the job
starts green. CA2027 (a Task.Delay that lost a WhenAny race keeps its timer alive)
in the Studio process launcher, the capture settle and context, and the Terminal.Gui shutdown grace
period, now WaitAsync(timeout); CA2026 (JsonDocument.Parse(...).RootElement leaks
the document) at 55 sites in MCP and the tests, now JsonElement.Parse; CA1860
(Any() on a collection with a Count) at 3 sites in test doubles. Both dotnet
tools (orkeon, orkeon-repl) now declare RollForward=Major: a machine whose only
runtime is .NET 11 runs them instead of printing "You must install .NET". The target
framework, global.json and the container base images do not move before GA.
Changed — the pre-release upstream of Orkeon.Tools.Embeddings.Local is assumed, in writing
SmartComponents.LocalEmbeddings has one version, a pre-release, and an archived
upstream. Rather than vendoring its inference wrapper, the packaging project of the
opt-in silences NU5104 — the only project that ever carries the dependency — and the
decision is recorded where it is read: one line in the README's installation table, a
paragraph in limitations.md. Verified: dotnet pack Orkeon.sln -p:VersionSuffix= -warnaserror
produces the nine 1.0.0 packages, the opt-in depending on the pre-release as declared.
Changed — the README opens on the problem, then on a command that runs without a key
The front page used to open on one crew, three ways and a YAML block — a pitch to
someone who had already decided, on the one promise Microsoft Agent Framework has also kept
since July. It now opens on the problem: a coding agent let loose on a repository writes
where it should not, because nothing is there to stop it. Then on what Orkeon puts in front
of the model — the virtual file system and its declared mounts, the sandbox, the execution
budget, and Orkeon.Compliance.Vfs as a standalone analyzer for the reader's own code —
and on a three-line block that runs a crew on llama3.2:1b with no API key:
examples/quickstart/ (one agent, one file_write, one rw mount, its own
appsettings.json pointing at Ollama). Measured on 2026-09-11: the crew writes
out/hello.md in 14 s; the same crew asked to write /etc/hello.md is refused by the
file-system service before any byte lands (No mount found for virtual path). orkeon forge gets its own section — the brief → blueprint → render → validate → test → diagnose
→ verdict cycle, the four commands, the FORGE-LLM-UNAVAILABLE door. Quick Start —
one crew, three ways moves down, unchanged. The compliance vocabulary — NIST, DLP,
memory encryption, key rotation — leaves the README and the docfx landing page; every
subsystem stays in the code and in the reference pages (opt-in-subsystems.md,
security.md). index.md / index.fr.md open the same way.
Added — the README's quickstart block is executed by CI, literally
quickstart.yml cuts the fenced block between the <!-- quickstart:begin --> /
<!-- quickstart:end --> markers out of README.md, checks that README.fr.md carries
the identical block, and runs it on a GitHub runner: a pinned Ollama release checked
against its published digest (no curl | sh), the orkeon tool installed by the block's
own dotnet tool install line — resolved to a pack of the checkout under test through a
nuget.config that names that pack as the only source — then out/hello.md must exist
and say hello. Change the README, the job runs the new README; break it, the build is red.
Fixed — a tool-call envelope inside the arguments no longer trips the circuit breaker
llama3.2:1b answers a file_write call with the whole envelope as the arguments —
{"type":"function","function":"file_write","parameters":{"path":…,"content":…}}. The
dispatcher passed it through, the tool saw no path, said so, and the model repeated the
identical envelope until the circuit breaker tripped after three iterations. When every
key of the arguments belongs to that envelope (type, function, name, parameters,
arguments) and the payload is an object — or a JSON-encoded one — ChatToolDispatcher
now unwraps it. Anything else is left exactly as it was.
Changed — coverage is measured in public, and the README quotes no number for it
The README stated 82.7 % line coverage from a SonarQube pass on one machine whose report
the repository does not track. A new coverage.yml workflow runs the unit and fast suites
under a pinned dotnet-coverage on every push to main and weekly, refuses an empty
measurement, writes the summary on the run page and keeps the Cobertura file plus an HTML
report as an artefact. The README's status section links to the workflow and quotes
neither the coverage nor the SonarQube figures any more; quality-gate.md explains where
the checkable number comes from and what it covers.
Changed — the numbers on the front page are counted by one rule, printed with it, and exact
Three sources gave three tool counts: 75+ in the README, 79 in CLAUDE.md, 73 from
a grep by base class. All three were "true" under their own rule; none published it. The
rule is now one — a *Tool.cs file under src/ is a built-in tool (the grep undercounted:
the four relational-database tools, the two file-search tools and the code interpreter
derive from intermediate bases) — and the number is 79 on every page, exact: the
N+ floor check-doc-claims.py used to accept is rejected as a number nobody can check.
The example count is measured on disk (examples/NN-*/NNN-*/ holding a config.yaml or
a main.ork.ts, 105) and examples/INDEX.md is checked against it, rather than the other
way round. The memory-store count (6, classes deriving MemoryProviderBase) joins the
gate. scripts/count-surface.sh — check-doc-claims.py --surface — prints every
front-page number next to the rule that produced it, JSON on request. The walks are
pruned at the directory level: the gate took four minutes on this tree, it takes seventy
seconds.
Added — the provenance chain is documented, verified on rc.3, and closed with an SBOM
The CI already did what almost no .NET open-source project does — Trusted Publishing by
OIDC, actions/attest-build-provenance on every package and every release asset,
ContinuousIntegrationBuild at pack time, SHA256SUMS per channel — and said so in one
line of one reference page. Verify what you install
(EN + FR) now gives the exact gestures, each run against the published v1.0.0-rc.3
artefacts before being written down, and states what the chain does not prove.
Two measurements shaped the page. A release asset verifies as downloaded: the GitHub
attestation API returns one SLSA v1 statement for orkeon-cli-1.0.0-rc.3-osx-arm64.tar.gz,
builder release.yml@refs/tags/v1.0.0-rc.3, eleven subjects. A package downloaded from
nuget.org does not: nuget.org repository-signs every package by appending a
.signature.p7s entry, so its digest is no longer the attested one. The signature is
always the last entry, so scripts/nupkg-unsign.py (standard library, no re-zipping)
recovers the original bytes exactly — measured digest
5800062e…cbb8e7 for Orkeon.1.0.0-rc.3.nupkg, which the API resolves to the
publish.yml statement with its nine subjects. SECURITY.md gains a Verifying what you
install section and the README installation table a Verify what you download row.
The one missing piece of the chain was the cheapest: both publish.yml and release.yml
now generate a CycloneDX SBOM of Orkeon.sln (CycloneDX dotnet tool 6.2.0, pinned;
199 components on rc.3) right after the build and cover it with the same attestation —
a release asset with its SHA256SUMS line, and a sbom run artefact for the package push.
Fixed — Orkeon.Compliance.Vfs reaches NuGet.org, and works once it gets there
The analyzer was IsPackable, packed at every tag, and never pushed to nuget.org: the
publish lineup was a fixed list of six ids in two places. It is the seventh now, in all
seven hand-maintained copies check-doc-claims.py compares.
Pushing rc.3 would have shipped an inert package. Built against the repository's pinned
Roslyn 5.9.0, it was refused by the compiler of a stock .NET 10 SDK (10.0.301 ships
5.6.0) with CS9057 — a warning, after which the analyzer is silently skipped. A fresh
project with a File.ReadAllText call built clean. The package is now compiled against
Roslyn 4.8.0 (the .NET 8.0.100 compiler; VersionOverride on the one reference), and the
same fresh project reports ORKVFS001 and ORKVFS002 as errors. Its README is rewritten
for a consumer who has never heard of IFileSystemService: the seven rules, the path
exemptions, the name-matched suppression attribute to declare locally, .editorconfig
severities.
Changed — the front page stops asserting what git tag already says, and says who answers
The README and CLAUDE.md claimed "the latest tag is v1.0.0-rc.2" two days after
v1.0.0-rc.3 was tagged and its six packages were on NuGet.org. Tag state is no longer
asserted in prose anywhere: scripts/check-doc-claims.py rejects the sentence shapes that
rotted (not yet tagged, latest tag is, and their French forms) and, when the clone
carries tags, fails the build if a v* tag newer than the props version exists — the
version bump that follows a release can no longer be forgotten silently. ci.yml checks
out with tags for that purpose.
.github/CODEOWNERS routed every review to @Orkeon/maintainers, a team that does not
exist — which GitHub treats as no owner at all. It now names the maintainer account.
SUPPORT.md gains an If the project stops section: MIT, a build reproducible from a
public clone, every action and base image pinned, no private infrastructure on the path —
so a fork that keeps the tests green is a full replacement. The README links to it.
CONTRIBUTING.md opens its Areas for Contribution with a scope freeze: no 15th LLM
provider, no new built-in tool, memory store, language adapter or orchestration mode
until real users ask — one maintainer carries the whole surface. The former wish list
(Cohere, Vertex AI, Qdrant, a web UI, calendar tools…) is gone; interoperability,
observability, tests, docs and bugs are what remains open. limitations.md records the
rule and the issue chooser links to it before a proposal is typed.
1.0.0-rc.3 - 2026-09-07
The release candidate that opens the repository. Since 1.0.0-rc.2: the NuGet
distribution collapses from a per-layer lineup into a single Orkeon package plus
Orkeon.Tools and the opt-ins (PUB-25); the example catalogue drops its dedicated C#
trading runner and speaks TypeScript end to end; MiniMax and Grok bring the provider
fleet to 14 (Groq removed, no shims); the VFS boundary is enforced everywhere by its
own analyzer, and virtual paths became the only currency an agent is paid in (ADR-008);
constants shared by two projects moved to zero-dependency satellites (ADR-009); and a
full SonarQube campaign closed every issue above INFO — 0 bug, 0 vulnerability, 0
hotspot, technical debt down from 1 762 minutes to zero, A on all four ratings.
The public API surface is frozen at this tag: the 328 additions and 203 removals
accumulated since rc.2 move from PublicAPI.Unshipped.txt to PublicAPI.Shipped.txt
across the twelve projects that carried them, and the seven ORKVFS analyzer rules ship
with them.
Changed — the scripting DSL stops dropping half of what a script declares in silence
A .ork.ts file picks one of two engines by how it ends, and each engine honours what the
other ignores. A crew that declares tasks and ends with await crew.run() ran its agents
and never looked at the tasks; a crew that declares .body() and hands itself off with
globalThis.crew = crew ran its tasks and never invoked a body. Both produced a run that
succeeded, printed a plausible result, and said nothing about the half it had thrown away.
Both halves now warn, symmetrically and by name. JsCrew.RunAsync reports the tasks it
will not read (with the count), a manager it will not use, and a process(...) that
reaches only a telemetry tag. JsCrewConfigurationAdapter.CollectIgnoredFeatures reports
.body(), .withState, .onError, .onAgentStart/.onAgentStop, budget() and the
crew hooks — each naming the agent it was written on, because "a body was dropped" in a
six-agent crew is a second search. The runner logs them; orkeon run also warns on stderr
when --inputs, --inputs-file or --memory-limit-mb is passed to a script that hands its
crew off, since those options have no equivalent on that path and globalThis.inputs is
never planted.
.concurrency(n > 1) is deliberately not in that list: JsAgentBuilder.build() already
throws on it, so no crew carrying one can reach the adapter. A warning for an impossible
state is noise.
Removed — crewBuilder().graph(), a method whose argument no engine ever read
.graph(stateGraph) stored its argument in a private field that reached neither
JsCrewDefinition nor JsCrew nor the declarative adapter — and process("graph") refused
to build without it. The one crew mode that needed the method was gated behind a method that
discarded what it was given, and no file in the repository ever called it.
The two facilities share a word and nothing else. process("graph") runs the crew on the
domain's graph strategy — a fixed agent_execute → route_decision loop with a circuit
breaker, tuned by GraphConfig. stateGraph({ nodes, edges }) is the topology the script
draws, and it runs on .run() from an agent .body(). Removing the method unblocks the
mode: process("graph") now builds on its own. Same treatment as when() earlier in this
release, and for the same reason — nothing is released at rc.3, and this repository takes no
compatibility shims.
Fixed — the ONNX crash CI had been tolerating was a call into a disposed session
LocalEmbeddingProvider, shipped in the Orkeon.Tools.Embeddings.Local package, set a
_disposed flag in Dispose() and never read it again. EmbedBatchAsync and
Dimensions went on to use the LocalEmbedder whose native ONNX session had just been
freed. Both now throw ObjectDisposedException, so a disposed provider refuses the call
instead of dereferencing freed memory — which for a library means it can no longer take
its host process down at shutdown.
The visible symptom was a test suite that killed its own process.
Dispose_Releases_Embedder asserted that a disposed provider "raises any exception",
an assertion written over undefined behaviour. On a warm heap the freed session returned
nonsense (OnnxRuntimeException: input name cannot be empty); on a dirty one it was a
SIGSEGV. Measured: the class crashed 5 runs out of 5, while each of its tests run alone
crashed 0 out of 3 — the crash needed the earlier tests to dirty the heap first. After
the fix, 17 consecutive runs exit 0 with all 11 tests green. The test now asserts
ObjectDisposedException exactly, and a second one covers Dimensions.
That crash had been recorded as an ONNX Runtime teardown artefact (PUB-17 / SONAR-14).
ci.yml and publish.yml each carried a step tolerating exit 139 whenever "every
discovered test was accounted for" — but the accounting came from the crashed process
itself, so a truncated run always matched, and a crash landing before the first result
reported zero tests and failed the step anyway, which is how the CI run of 2026-09-07
went red. Both steps are plain dotnet test runs again, integration.yml no longer
excludes the project from the nightly sweep, and docs/reference/limitations.md (EN+FR)
now records what the crash was instead of what it was taken for.
Fixed — the final pre-publication review: what eleven reviewers found in the tree they were about to make public
Security. Web tools no longer share the host's ambient HttpClient:
AddOrkeonWebTools registers its own named client
(WebToolExtensions.HttpClientName) with AllowAutoRedirect = false, closing an
SSRF-by-redirect hole where a validated first hop could redirect a request to a
metadata endpoint the guard never saw. The RAG WebPageLoader gained the guard
it never had: it validates through IUrlValidator before any fetch and fails
closed when no validator is registered, so rag_ingest can no longer be
pointed at 169.254.169.254. Both SSRF guards learned the IPv6 addresses they
were letting through -- ::, ff00::/8 multicast, the 64:ff9b::/96 NAT64
prefix, and IPv4-compatible ::x.y.z.w forms judged against the IPv4 table --
and a new test pins the two tables against each other so they cannot drift.
http_api now runs LLM-supplied headers through the header sanitizer instead of
forwarding them verbatim, with a default sanitizer backing every construction
shape, so a model can no longer set Host or Cookie or smuggle a CRLF. The
shell tool clears the child's environment down to a named allowlist rather than
handing it every variable the host process holds, and its read-only git
guarantee inspects every token instead of the subcommand alone (git log --output=/tmp/x was a write). LogSanitizer learned the key shapes this
repository actually ships -- underscores inside keys, xai-, hf_, tgp_v1_,
tvly-, xox[abp]-, AIza, and the dotted DashScope form -- and the two
sk- patterns became one, which also fixed a case where the narrower pattern
matched first and left the tail of a key in the log.
Contracts that lied. SandboxOptions.RequireHumanApproval and
PreferredSandbox were public, documented, bound from configuration and read by
nothing; an option that promises a human gate and does nothing is worse than no
option, so both are removed. LlmConfig.ApiKey stopped being [Obsolete]:
it steered every caller to ApiKeySecretName, which the framework never
resolves, so following the compiler produced an unconfigured provider --
ApiKeySecretName is now documented as reserved and the limitation is recorded.
Azure OpenAI's direct streaming path threw instead of ending on silence, joining
what the other providers already did; and the three buffered streaming fallbacks
(RateLimitedLlmProvider, HttpLlmProviderBase's default, the IChatClient
adapter) stopped swallowing a provider's refusal and re-emitting it as an empty
stream.
Distribution. Both tool packages redistribute ONNX model weights, and neither
carried THIRD-PARTY-NOTICES.md; the notices now travel with them, in the two
nupkgs, the installer archives and the Debian package. The package-closure gate
walks the whole transitive ProjectReference tree, so an assembly that ships in
no lineup package fails the gate instead of reaching a consumer as a
FileNotFoundException.
Command line. orkeon --help, orkeon help and a bare orkeon print a
usage page listing every verb and exit 0, instead of listing nothing and exiting
1; an unknown first token is rejected by name rather than falling through to
run.
Studio. Three English catalogue values still named French buttons; the
orphan-key drift gate filtered on a key prefix no key carries, so it checked
nothing; and a resumed forge session dropped most of the metrics
last-run.json carries.
Documentation. The pages stopped describing a repository that does not
exist: the install lines carry --prerelease, the phantom --config flag became
the positional <config> the CLI really takes, examples/README.md names the
environment variable the runtime actually reads, the release pipeline is drawn
from release.yml's own job graph, the quality-gate policy dates its
measurements against the September report, SECURITY.md admits that
shell_command ships registered, the runner logging default is Warning and says
so, the English Studio page quotes the English catalogue, and the documentation
site has a published address. Every src project has a README.
Two more guards, caught by the same pass. The Guardian's own SSRF check was the
third guard on this surface and had kept a hand-written IPv4 regex while the other
two were hardened; it now judges through UrlValidator's tables, so [::1],
[::], the IPv4-mapped and NAT64 forms of the metadata endpoint and 100.64.0.0/10
are refused where they used to pass. And DockerSandbox — the boundary SECURITY.md
points at for untrusted code — started its container as root with the daemon's
default capability set; the run line now always carries --cap-drop=ALL,
--security-opt=no-new-privileges and --pids-limit.
Three more, on the same security surface. The URL validator logged the credentials
it had just refused: an embedded-credentials denial wrote the password into a Warning
line, and the scheme and port denials logged the query string. Log lines now carry
scheme, host and port only. The VFS analyzer could not see using static System.IO.File; ReadAllText(p) nor FileStream fs = new(path, …), so both shapes escaped ORKVFS001-006;
it now judges invocations on the resolved symbol and registers the target-typed creation
form. And the sandbox's Roslyn denylist ignored Environment.Exit,
Environment.GetEnvironmentVariable (the environment holds every API key the host
resolved), AppDomain.CurrentDomain.Load(bytes), the target-typed Process p = new();
and unsafe used as a method modifier — all five are now flagged.
Gates. check-doc-claims.py compares the CONTRIBUTING copies of the NuGet
lineup (six copies now, not four) and checks the push order in each;
check-comment-accents.py reaches unaccented French in C# comments, which is how
twenty-seven comments quoting French UI labels had survived the catalogue
rewrite.
Fixed — a provider that cannot stream now says so, and the clocks stop deciding tests
ILlmProvider.SupportsStreamingfollowsIsConfiguredinstead of being an unconditionaltrueon all 14 providers. An unconfigured provider used to declare streaming, the caller took the SSE branch, and the branch ended without a single chunk — a script'sfor awaitcompleted on a silence indistinguishable from a model with nothing to say, while the very same provider's buffered path said "API key is required" out loud. A direct streaming call on an unconfigured provider now fails in the open, and the streaming and buffered paths render the same marker. Consumers that branch on the declaration (LlmProviderToChatClientAdapter,RateLimitedLlmProvider, the scriptingllmfacade) see it change with the configuration.AgentWorkloadTrackerandTaskExecutionRoutertake an injectedTimeProvider. Five test classes stop measuring wall-clock time — three of them were assertion bugs rather than timing ones.- Publishing hardening: both release workflows attach build provenance attestations,
the container base images are pinned by digest (Ollama stops floating on
:latest),NuGetAuditis declared rather than inherited as a side effect of-warnaserror, and a tag carrying a prerelease suffix now publishes as a prerelease instead of becomingLatestby omission. - The secret-scan allowlist stops masking whole files and names the individual values it accepts, so a real credential added to an allowlisted file is still caught.
- Two tests stopped depending on the machine: the
Retry-Afterpolicy test reads a monotonicStopwatchinstead ofDateTime.UtcNow, and the crew-host start/stop test waits for the hosted loop to announce itself instead of racing it. - The strict docfx build (
--warningsAsErrors) is green again — the API landing page is reachable from the table of contents, and the generated SonarQube reports joined the site's content set instead of dangling as broken links. - The LLM campaign kit (
llmproviders-test/) generates its reports and its regenerated index in English; reports dated before 2026-09-06 stay French, and the index says so.
Fixed — SonarQube campaign: 186 issues resolved, BLOCKER through MINOR [breaking — constructor shapes]
A full analysis (SonarQube 9.9.8, scanner .NET 11.2.1, Sonar way C# profile) on
the renamed Orkeon project reported 186 issues in the BLOCKER..MINOR band across
119 files, plus 17 security hotspots left TO_REVIEW. All 186 are now closed and
every hotspot is reviewed: 0 bug, 0 vulnerability, 0 hotspot, 0 issue above INFO,
technical debt 1 762 min to 0, and reliability / security / maintainability /
security-review all rated A. Coverage (82.7 %) and duplication (1.6 %) are
unchanged — this campaign moved no test and added no dead code.
- 11 BLOCKER
S2699— tests that asserted nothing now assert what they claim to verify (Orkeon.Host.Tests,Orkeon.Infrastructure.TestsEventHub,Callback,Analysishybrid search,Studio.Core). - 42
S3776— cognitive complexity brought under 15 by extraction, not by splitting:ForgeCommandOptions.Parse(58),ForgePromote.WriteCard(50),ForgeEngine.RunAsync(30),RagNamespaceBinding(26),RunnerExecution(21),LlmProviderFactory(19),OpenAICompatibleProviderBase(19), and 35 more. - 35
S3358nested ternaries, 18S107over-long parameter lists, 11S125commented-out code, 7S3267loops replaced by LINQ, 5S1168null collections, and the mechanical tail (S927,S1186,S1144,S2365,S2479,S1854,S3264,S3871,S2223,S1751,S3903, ...). - 20 findings arbitrated as false positives, each carrying an in-code
[SuppressMessage](or a local#pragma) that states why:S101onI18n(the rule proposes18N),S3604onLock _gate = new()fields of primary- constructor types,S107on[LoggerMessage]partials,S1168wherenullis a third state the caller reads (ICrewLinkProvider.LinksFor, the shell-tool allowlist),S2737on catch clauses that exist to carry an exception filter,S3925on the EventHub exceptions,S3871on an executable-internal exception. - 17 security hotspots reviewed SAFE: 13 x
S2077(the only interpolated fragment is an allowlist-validated, quoted SQL identifier — SQLite cannot parameterize identifiers in DDL/PRAGMA; every caller value is a command parameter), 2 xS4792(the framework's own logging wiring), 2 xS5443(/tmpis a virtual VFS path, not the shared OS temp directory).
Breaking (source): four constructors that took more than seven dependencies now
take a single grouped dependency object — a sealed record for the three RAG ones, a
sealed class for CrewStrategyDependencies. No shim is provided — call sites move
with them.
Orkeon.Infrastructure.Crew.Strategies.CrewStrategyDependencies(new) replaces the(taskRepository, agentRepository, executionService, memoryScope)quadruple inSequentialProcessStrategy,GraphProcessStrategy,AutonomousProcessStrategyandConsensualProcessStrategy.Orkeon.Rag.Pipeline.StagedRagPipelineDependenciesandOrkeon.Rag.Pipeline.IngestionPipelineDependencies(new) replace the optional collaborator tails ofStagedRagPipeline,CorrectiveRagPipelineandDefaultIngestionPipeline.- The remaining
S107sites inOrkeon.ScriptingandOrkeon.Hostingfollow the same shape; every move is declared in the affected projects'PublicAPI.Shipped.txt(theUnshippedfiles stay header-only, as the release-readiness gate requires).
Out of band and left as-is: 406 INFO issues, 404 of them xUnit2033 (use the
value Assert.Single returns instead of re-indexing) plus two SYSLIB hints.
Changed — the example catalogue speaks TypeScript, and the trading assembly retires [breaking — one package removed]
The numbered catalogue had two ways to run: the orkeon CLI for most of it, and a
dedicated C# runner for the fifteen finance crews, dragging a 14 000-line assembly and
its own package behind it. It now has one.
- The fifteen finance crews are
main.ork.ts— every process type (hierarchical with managers, parallel fan-outs, sequential, consensual), memory, the threehumanInputreviews, dependency DAGs, built-ins by name, and their tools attached as instances. All validated end to end through the same strict pipeline as YAML. The catalogue holds at 105 examples. - A shared
_toolsTypeScript module replacesOrkeon.Trading.Tools: 27 tools over seven category files plus a small financial-math core (correlation and covariance matrices, Acklam's normal inverse CDF, a seeded mulberry32 PRNG). Every tool is deterministic — same input, same output — which theRandom-based C# originals never were. Simplifications where the C# leaned on MathNet are documented in place (grid-scan mean-variance, convex-blend Black-Litterman, midpoint-bisection HRP); everything else is a faithful port, Wilder's full ADX included. The seventeen tools no finance config ever referenced were not ported. - Removed:
Orkeon.Trading.Tools(162 files), the trading runner, the two interactive example runners and theexamples/_sharedlibrary — from both solutions and from the disk.publish.ymlno longer packsOrkeon.Runners.Shared: that package is discontinued.MathNet.NumericsandYahooFinanceApileave the package versions with no consumer left, the container image drops its trading publish stage and banner line, and the installers drop theorkeon-tradinglauncher. - A declarative
.ork.tsnow runs through the real pipeline. A bareorkeon run crew.ork.tsused to execute a flat loop over agent bodies that ignored tasks, process, manager,humanInputand deliverables — the full orchestration was reachable only behind--config.runnow routes to the shared one-shot runner whenever the script declares theglobalThis.crewhandoff, so the script is evaluated exactly once, by the pipeline. Procedural scripts keep the script path untouched. - Every surface that discovers, validates, lints, indexes or launches the catalogue
accepts
main.ork.tsbesideconfig.yaml:run-example,test-all-examples,validate-all-examples, the config linter (which grew a TypeScript lint checking every quoted tool name against the manifest and the shared module), the index generator, and the container'sorkeon-exampledispatch. The guides say one CLI, EN and FR. - Incidental: a Cyrillic-homoglyph agent id the 36 finance YAML files had carried since
birth is ASCII at last, and the catalogue-wide
CA5394waiver lifts — the seeded-PRNG TypeScript successors made it moot.
Changed — one Orkeon package instead of a per-layer NuGet lineup (PUB-25) [breaking — packaging only]
The Domain/Application/Infrastructure split is an internal discipline, not a
distribution contract — and shipping it as three packages had already produced
one real incident (rc.1/rc.2 published on NuGet.org with five unrestorable
Orkeon.* dependencies). Distribution is now consolidated; the 39-project
source layout, namespaces, and per-assembly PublicAPI freeze are untouched, so
consumer code compiles as-is — only the install line changes.
Orkeon(new): the whole framework in one package — eleven embedded assemblies (Orkeon.Domain,Orkeon.Application,Orkeon.Infrastructure,Orkeon.Rag(.Abstractions),Orkeon.Analysis(.Abstractions),Orkeon.Tools.Abstractions, and the three constants satellites of the core graph). Migration: uninstall the per-layer packages,dotnet add package Orkeon --prerelease.Orkeon.Tools(new): the seven built-in tool families in one package, separate fromOrkeononly for dependency weight (database drivers, PDF and spreadsheet libraries live here). Depends onOrkeon.Orkeon.Rag.OnnxandOrkeon.Tools.Embeddings.Localnow depend on theOrkeonpackage instead of the discontinued per-layer ones.- The per-layer, per-family and deferred-library packages (
Orkeon.Domain,Orkeon.Application,Orkeon.Infrastructure,Orkeon.Constants.*,Orkeon.Tools.<family>,Orkeon.Cli.*,Orkeon.Scripting,Orkeon.Hosting,Orkeon.Plugins) are no longer packed; the published rc.1/rc.2 of the three core packages are to be unlisted on NuGet.org. publish.ymlpushes the new lineup (Orkeon,Orkeon.Tools, the ONNX reranker pair, the local-embeddings tool package, and theorkeondotnet tool) and a new gate —scripts/check-package-closure.py— fails the workflow if a lineup package depends on anOrkeon.*id outside the lineup or if an umbrella's hand-declared external dependencies drift from its embedded projects.
Fixed — publishing hygiene ahead of the public opening
- Release builds emit embedded portable PDBs again (
DebugSymbols=falsehad silently disabled emission, shipping non-debuggable packages with no SourceLink attachment point);EmbedUntrackedSourcesis on. - The
orkeondotnet tool package no longer bundles the iOS/Android onnxruntime natives a CLI tool can never load: 262.5 MB → 137.6 MB, back under the nuget.org size limit. - The docfx API reference now covers the five
Orkeon.Constants.*assemblies, andnamespaceLayout: flattenedremoves the 26 dead breadcrumb links the nested layout generated. - A missing
--mountsource directory now fails with a clean actionableERROR:line and exit 1 — in the YAML runner,--validate,--list-toolsandorkeon runalike — instead of a raw dependency-injection stack trace. CrewConfigurationMapperreports skipped tools and failed LLM-provider resolution through an optionalILogger(source-generated warnings) instead of writing to the console from the Application layer; theExampleCallbacksdemo handlers moved out of the publishedOrkeon.Applicationassembly into the test suite that was their only consumer.- Supply-chain hardening: a root
nuget.configpins nuget.org as the only package source (with wildcard source mapping); esbuild moves to^0.25.12(GHSA-67mh-4wv8-2f99) and every packaging-time esbuild tarball download now verifies the sha512 integrity recorded in the lockfile; Dependabot watches the Docker base images (root + deploy).
Changed — the screenshot campaign photographs an application in use, not an empty one
orkeon-studio --capture-screens <dir> existed to be the fidelity reference against the v3
mock, and could not be: it built the window over CreateForCurrentMachine, so on a clean
machine every shot was an empty card — no team, no history, no session, a wizard frozen on
step 1. Twenty-two images over roughly a hundred visual states, one theme out of the two the
v3 remediation promised, and no test at all beyond argument parsing.
The campaign now builds the window over a seeded scenario in a throwaway temp directory and walks every screen and every gated state of it, in both modes and both themes, plus a language sweep over the densest screens.
- Two worlds, never one mutated into the other. A populated machine (three adopted teams — one of which names a folder nobody declared —, seven past runs, four forge sessions, four model profiles, a declared folder that genuinely does not exist on disk, a doctor with one warning and one failure) and a first-run machine with nothing on it and no CLI installed. "Empty versus populated" is a thing a stop declares rather than a teardown that must un-populate a list.
- The real loaders, over seeded files.
TeamCatalog,ForgeSessionCatalog, the file-backed history and profile stores, the physical settings, directory and target probes — all of them, pointed at the sandbox. Only the process boundary is doubled, because it has no other seam.ForgeSessionHydratorrebuilds a whole wizard session from five JSON files, so the Composer, the mount rows and both verdicts are photographable with no child process anywhere. - The catalogue is data, and it is asserted. Each stop declares where it stands, what it arranges, why that state is worth a pixel, and which ViewModel gates it claims to light up. The whole campaign is replayed headless on the Linux runner and every claim is checked — so a stop that stops reaching its state fails the build instead of writing a confident picture of the wrong screen. Writing those assertions immediately caught four wrong claims, including a stacked-modal state this application does not have.
- Conformity guards, in the idiom of the suite's existing ones: every sidebar entry maps to a capture screen, every scrim modal is opened by some stop, every endless storyboard has a pose, every guided-tour step names an element that exists.
- Output that cannot lie. Per-stop fault barrier; geometry, non-uniformity, expected-panel
and binding-error checks; a differs-from-the-previous-shot digest that catches the likeliest
failure of all, a stop that changed nothing; atomic writes; failed shots quarantined under
failed/; and amanifest.jsoncarrying each image's reason, its SHA-256 — so re-running the campaign after a UI change and diffing two manifests names the exact screens that moved.
Fixed — five defects the campaign was hiding
Found while making the collection trustworthy; each produced, or was about to produce, an image that looked like evidence.
- The startup plate would have bled into every shot.
SkipSplashForCaptureran a zero-duration animation and setVisibilityfrom itsCompletedhandler — and a zero duration is not a synchronous completion:BeginAnimationattaches the clock and the media context ticks it on the next render pass. It worked only because the settle slept 120 ms afterwards. Replaced by a direct pose/hide pair, alongside aPoseSplashForCapturethat photographs a plate a user could actually have seen instead of five pixels of progress bar. - The theme button contradicted its own window in dark mode. The icon and tooltip were
refreshed only from the toggle handler, so applying the theme any other way left a moon in the
title bar — the first place a reviewer looks.
ApplyThemeForCapturedoes both, and deliberately does not touch the operator's stored preferences. - The language menu could never appear. It is the app's only
Popup, hosted in its ownHwndSourceand therefore invisible toRenderTargetBitmap: its shot would have been a chevron rotated to 180° above nothing. Popups are now composited into the window's image, with the computed placement asserted inside the window's bounds rather than trusted. - The settle proved nothing. WPF orders
LoadedbelowRender, so the second dispatcher fence returned immediately andTask.Delay(120)was the only thing creating slack. Replaced by a bindings drain, a layout loop that repeats until layout stops dirtying itself, bounded composition ticks and an idle drain — with an unstable layout recorded rather than ignored. - A failed shot was invisible and inflated the count.
Savereturned silently on a zero-size window after the index had been incremented, and the count printed on stdout was the stop count, not the file count.
Also: the collection is captured at 1440×900 rather than 1024×768. At the old size the content
column is 752 DIP and the panels declare maximum widths of 880, 1000 and 1080 — so none of them
ever bound, and every image ever produced showed the narrowest layout the app can make, which is
not the one the mock was drawn for. captures/ joins .gitignore.
Fixed — the pre-push audit: what forty-one reviewers found in the campaign season's own commits
A six-dimension adversarial audit of the fifteen unpushed commits (token/cache accounting, multimodal, dialect hooks, fleet coherence, documentation, commit hygiene) confirmed no accounting defect and no payload defect — and a crop of real gaps at the edges, each fixed test-first:
- MiniMax, truncated mid-thought: a reply cut by
max_tokensinside the<think>block never reaches the closing tag, and the split hook shipped the whole raw trace as visible content — the exact failure the hook exists to prevent, on the exact path where the model is most verbose. An unterminated leading block now yields empty content with the partial trace inreasoning_content. The hook's edges are pinned while at it: a mid-content<think>survives (quoting is content), an empty block records nothing, and the stream-final scrub is now a test, not a hope. - The streaming chat path dropped images silently:
ChatStreamingAsyncbuilt the same payload as the buffered path without callingWarnOnUnsendableAttachments, so a non-vision provider streaming a multimodal conversation lost the image without a word — the guarantee depended on which transport the caller picked. Both paths warn now. - Ollama's URL-only images: the converter documents that an image referenced only by
URL is "reported as skipped rather than silently dropped" (the server never fetches
URLs), but nothing reported it.
BuildChatPayloadnow emits the structured warning with the remedy (inline the bytes). - The None warnings claimed too much: "this API has no response-format field" is factually wrong for MiniMax — the only provider that triggers it — whose API accepts the field and ignores it; same for "no reasoning pass" on a model that always reasons. Both texts now state the honest diagnosis (not honoured / no control), and MiniMax's two uniquely reachable warning branches are under test.
- Studio called the mainland MiniMax "custom": the detector mapped only
api.minimax.io;api.minimaxi.com— a constant this very range introduced, routed by the runtime factory — now detects asminimax, the Kimi twin pattern. llm.minimax: the scripting namespace gained the accessor (binding, provider-name switch, typings, PublicAPI), asllm.grokhad — a first-class provider should not be reachable everywhere but from a script.llm.grokgets its first pin alongside.- The registry's promise is now kept in print: "the report header prints the values
actually used" was false for the M7 effort (
mistral-medium-2604probes athigh, the header said nothing) — the probe report (markdown + JSON) and both kit report writers now print the base and M7 efforts whenever a run departed from the default, and--thinking-effortis a real flag of both campaign scripts (explicit beats catalogue), as their options table already claimed. - Documentation swept against the code at HEAD: the fleet is 14 everywhere (eight
pages EN+FR still said 13, one said twelve); the response-format matrix lost its stale
contradictory
Gemini | Nonerow and gained the missing MiniMax row; the vision table no longer lists DeepSeek as both having and lacking the capability and counts all 14; the opt-in page's "12 of the 13 declare it (only DeepSeek does not)" was wrong on both halves; every "campaign pending / non campagné / NOT yet campaign-verified" left over from MiniMax's documentation-first landing now states the campaign;--m7-effortreached the CLI reference; the Grok custom-endpoint report's paste-ready journal line named a path that does not exist; and this file's ownmax_completion_tokensentry claimed Groq passed a campaign two sections after recording that Groq never ran one. - Pins the audit found missing: Gemini joins the fleet capability table (its
declaration was the only one unpinned), and the
xai-key-prefix inference — the last routing arm — gets the test that distinguishes it from a deleted one.
Added — MiniMax, the fourteenth provider — the first documentation-first integration
Unlike Grok, which arrived preceded by its own live campaign, MiniMax arrives with no key
and says so everywhere: MiniMaxLlmProvider (OpenAI-compatible, api.minimax.io/v1
international with the api.minimaxi.com mainland twin named, the Kimi pattern), canonical
key minimax, default model MiniMax-M2 — every declaration sourced from the vendor's
platform documentation dated 2026-08-30 and marked campaign-pending in the code, the
comparison table (a † row), and the campaign catalogue. response_format and thinking stay
undeclared (capability warning, never a silent drop — the Gemini precedent, upgradeable the
day a key arrives); vision follows the documented VL family per D-03. The catalogue entry
records the three questions the first campaign must settle: DeepSeek-style reasoning
replay, cache breakdown, and whether response_format works despite being undocumented.
Full fleet integration otherwise: factory routing (key, both regional hosts, minimax-*
model prefix), Studio card and detection, doctor, orkeon llm probe|models, example
settings, counts 13 -> 14 under the claims gate.
Fixed — MiniMax speaks its mind out loud, and the dialect now separates the two
The first MiniMax campaign (same day as the integration — 7/2/3 on MiniMax-M2, once the
right key arrived: sk-cp- keys are coding-plan quotas, sk-api- is the API) settled all
three recorded questions and found one real integration defect. MiniMax ships its reasoning
INLINE: every reply opens with a <think>...</think> block inside content, no separate
field — a one-line hello came back as 158 characters, and an agent built on it would speak
its private reasoning out loud. The dialect now splits the block into reasoning_content
(a SplitReasoningFromContent hook on the compatible base — the MiniMax twin of the
Mistral chunked-content fix) and re-inlines it verbatim when replaying an assistant turn,
because the vendor documents that history must keep the think blocks. Post-fix re-campaign:
a 24-character hello. The other two answers: response_format is accepted but NON-BINDING
(a schema is ignored, json_object arrives fenced in markdown — the None declaration
graduates from caution to measurement), and the implicit cache reports no breakdown at an
8k prefix. The standing reds are the model's: system message ignored on both shapes, and
"I'm unable to view the image" — text-only per model (D-03), with the VL family absent from
the platform's /models listing, so no vision companion is declarable yet.
Removed — Groq, superseded by Grok (breaking, no shims)
Groq was never the intended provider: the near-homograph had stood in for Grok since the
provider list was first drawn. With Grok landed and Groq never campaigned (no key ever
supplied, no archived proof), the fleet drops it outright per the pre-release rule - the new
state replaces the old one everywhere, no aliases, no shims. Gone: GroqLlmProvider, the
satellite constants (endpoint, key, default model), factory routing (key, groq.com host,
mixtral/groq model inference), the Studio card and detection, the scripting DSL factory
(llm.groq becomes llm.grok, which also retires its stale hardcoded llama-3.1 default),
the doctor mapping, the campaign-kit entry and the docs rows. The HuggingFace :groq
routing suffix stays - that is HF's partner vocabulary, not Orkeon's provider key. Provider
count returns to 13; the claims gate re-derived it and named every count to fix.
Added — Grok (x.AI), preceded by its own proof
The key supplied for "Groq" turned out to be an x.AI key (xai- prefix, refused by
api.groq.com, served by api.x.ai) — and the user's intent turned out to be Grok all along.
Before the provider existed, a full 12-mode campaign had already passed against api.x.ai
through the generic OpenAI dialect with nothing but a base-url (archived under
llmproviders-test/custom-endpoints/), so GrokLlmProvider is that measurement written
down: OpenAI-compatible transport on api.x.ai/v1, json_schema honoured, effort-only
thinking with the trace replayed, vision, implicit cache on the standard cached_tokens.
Canonical key grok (alias xai), default model grok-4.6 (verified live), endpoint and
key and default in the Orkeon.Constants.Llm satellite, factory routing by key, by
api.x.ai host, by grok-* model prefix and by the xai- key prefix — the grok/groq
near-homograph is exactly the confusion that last inference absorbs. Studio gains the
provider card and endpoint detection; orkeon llm probe|models accept it; the campaign kit
carries its catalogue entry; docs and counts follow everywhere the claims gate checks.
Fixed — what the first real campaigns against six vendors found (2026-08-30)
The first full campaigns ever run against api.openai.com, the Gemini compat surface and an identity-linked Anthropic key — plus re-runs of DeepSeek, Kimi, Z.AI and Ollama on rc.2 — turned five findings into fixes. Each one is dated and pinned by an offline test:
- OpenAI:
max_tokensis retired on current models. The campaign failed ten modes out of twelve ongpt-5.6-solover that one field ("Unsupported parameter: 'max_tokens' ... Use 'max_completion_tokens' instead"). The OpenAI dialect now writesmax_completion_tokens— verified live to be accepted by the older generations too (gpt-4o-mini) — through aMaxTokensFieldNamehook onOpenAICompatibleProviderBasethat onlyOpenAIProvideroverrides: DeepSeek and the rest of the compatible family still document and expectmax_tokens, and every provider campaigned that day passed with it. - Gemini:
response_formatworks and was being refused. DeclaredNonewhen the compat surface left it undocumented (2026-08-18), so every JSON request got a capability warning instead of being sent. Measured live: the surface acceptsjson_objectandjson_schemaand enforces the schema server-side (additionalPropertiesincluded). The declaration is nowJsonSchema. - DeepSeek: vision arrived, per model.
deepseek-v4-flash-vision-expreads a base64 image (measured live); DeepSeek was the one provider in the fleet with no vision model and its images silently degraded to text.Vision = truenow, with the same per-provider declaration / per-model reality the fleet already lives with (D-03): the text-only default model answers an image with the vendor's own error. - Anthropic: identity-linked API keys were unusable. They refuse every request without an
anthropic-workspace-idheader — Messages API and/v1/modelsalike — and Orkeon had no way to send one.LlmConfig.WorkspaceId(a scoping identifier, not a secret, same family asApiVersion) now travels as that header when set; classic keys change nothing. Wired throughorkeon llm probe --workspace-idand the campaign kit (workspaceIdper provider). - Ollama: every buffered completion was accounted as free. The buffered
/api/generateparse hard-codedTokensUsed = 0behind a comment claiming Ollama provides no count in that format — while the live server returnsprompt_eval_countandeval_countright beside the durations the same parse was already reading. Zero fed the token dimension ofAgentExecutionBudgetand the crew accounting. Found by the M1 probe archivingtokens=0on a priced exchange; the same gap left M10's diagnostic line reading "prompt tokens: unreported" for Ollama, because a tool-less conversation flattens onto that very path. Both now report (tokens=35,prompt tokens: 2052 then 2052on the re-run). - The probe harness accused the framework twice, wrongly. M5 rebuilt the assistant
tool-call turn from parsed calls while the real agent loop replays the vendor's raw
tool_callsfragment verbatim — Gemini rejects a replay that lost its per-callthought_signature, so the probe failed M5 for a defect the product does not have; it now replays the raw fragment whenever the body is OpenAI-shaped (the canonical rebuild remains for Anthropic's dialect). And M3/M4 asked for five numbers, which fits in a single event on a coarse-chunking stream (Gemini emits ~13-character chunks), reading a genuine stream as a buffered fallback; the probe now demands a hundred — thirty sufficed for Gemini, then claude-sonnet-5 coalesced the whole count-to-thirty into a single delta the same day.
The Mistral default model was broken and nothing could have said so offline:
LlmProviderDefaultModels.Mistral carried mistral-medium-3-5-26-04, an identifier the API
never served (Invalid model, measured 2026-08-30) — it reads like a concatenation of the
alias mistral-medium-3-5 and the vintage 2604, the two forms Mistral really serves. Any
configuration naming the provider without a model got a guaranteed 400. Now
mistral-medium-2604, the dated snapshot, verified live; same class as the retired defaults
LLM-01 fixed (G-01..G-04), found the same way — by the first real call.
The mandatory values some models dictate are now a per-model registry, not folklore:
requiredParams in the campaign catalogue (kimi-k2.6, gpt-5.6-sol and claude-sonnet-5 all
refuse any temperature but 1; gpt-5.6-sol additionally demands reasoning_effort: "none"
with function tools), resolved per model by both campaign scripts — per model and not per
provider, because gpt-4o-mini rejects reasoning_effort outright and a provider-level pin
would break it. Human-readable twin: the "Per-model mandatory parameter values" section of
docs/reference/llm-providers-comparison.md (mirrored in French), vendor wording and
measurement date included.
Mistral, first campaign ever (the key arrived last), peeled four findings in a row before
settling at 11/1 on mistral-medium-2604, with the G-14 proof in the header
(api.mistral.ai, not localhost, no base-url passed):
- The compiled default was the broken identifier above — first finding, fixed first.
- The model restricts
reasoning_effortto['high', 'none'], so M7's hard-coded "low" was refused: the probe's effort is now a per-model registry value (m7Effort, CLI--m7-effort). - Orkeon's omit-top_p-when-1 was a silent drop there. Mistral's reasoning mode runs an
internal top_p default of its own and validates greedy sampling against the EXPLICIT
field, so
temperature: 0+ reasoning with notop_pis refused ("top_p must be 1 when using greedy sampling.") while the same request with an explicittop_p: 1passes. The Mistral dialect now always writes the configured value (AlwaysEmitTopPhook — the base keeps omitting: OpenAI's reasoning models reject the explicit field, the same assumption broken in the other direction). - Reasoning replies were parsed as empty. With
reasoning_efforton, Mistral answersmessage.contentas an ARRAY of typed chunks (thinking+text) and the string-only read dropped both: M7 archivedaccepted, no reasoning trace returned, tokens=243— 243 tokens billed, nothing kept. The shared parse now walks the text parts (the OpenAI multi-part standard) and surfaces the thinking chunks asreasoning_content; the re-run archivesreasoning trace returned.
The one standing red is the vendor's: prompt_tokens_details.cached_tokens stays 0 on an
identical 5812-token prefix called twice (three measurements) — the field exists, the
implicit cache never hits.
Qwen, first campaign (an international key: dashscope-intl base URL, G-12 closed by the
report header): 12/12 — including the fleet's only Thinking = Budget wire
(enable_thinking + thinking_budget) validated live. Its M10 exposed a harness artefact,
not a vendor gap: DashScope's implicit cache hits nothing below a high threshold (0 cached
tokens at the probe's historical ~2050-token prefix, three runs; cached_tokens: 4352 at
5809), so a working cache read as broken. The M10 prefix now carries ~8200 tokens — the
clean run archives 6528 cached, ratio 0.81; Mistral's zero stayed zero at the long prefix
too, which settles its verdict as the vendor's. And the OpenAI-dialect fallback for custom
compatible endpoints — the path Docker Model Runner, vLLM and LM Studio ride — got its
first real proof: a full 12/12 campaign against api.x.ai (grok-4.6), archived under
llmproviders-test/custom-endpoints/. The key supplied as "Groq" was an x.AI key
(xai- prefix, refused by api.groq.com, served by api.x.ai) — measured, not assumed.
Together and HuggingFace, first campaigns (the last two keys): 9/1/2 and 8/2/2 — no Orkeon
defect on either. The one harness defect was found before the campaigns could even start:
Together answers GET /models with a bare JSON array, no data envelope, and the first
real call crashed orkeon llm models with an unhandled InvalidOperationException — the
parser now reads both shapes and the command's catch treats a malformed body as a typed
error (red test first, on the vendor's verbatim shape). The oldest suspicion of the effort
is settled by measurement: HuggingFace's compiled default meta-llama/Llama-3.1-8B-Instruct,
long flagged unreachable through the router, answers M1 alive — the campaign catalogue
realigns on it. Both M9 reds are the D-03 pattern; HuggingFace's vision companion
(Qwen3-VL-30B-A3B-Instruct) reads the image, while Together's four vision candidates are
all non-serverless on the measured tier (dedicated-endpoint only, failure archived as proof).
Campaign verdicts, same day: Kimi's open M3 "stream refused" of August did not reproduce
(4/4 green, replays included); Z.AI's implicit context cache missed once in-campaign
(0 cached tokens) and hit on both replays (1984 tokens, ratio 0.97) — server behaviour, not
a defect. Reports under llmproviders-test/.
Fixed — dotnet test runs again, and the tests it skipped are back
The .NET 10 SDK stopped honouring the VSTest target for Microsoft.Testing.Platform test
projects. Every dotnet test in this repo answered "Testing with VSTest target is no longer
supported by Microsoft.Testing.Platform on .NET 10 SDK and later" and ran nothing — CI, the
release pipeline, the nightly and the SonarQube script alike. global.json now opts into the new
runner ("test": { "runner": "Microsoft.Testing.Platform" }), which is the only supported answer;
the per-project TestingPlatformDotnetTestSupport property no longer does it.
Three things follow from the new runner, and each was a live defect rather than a rename:
- A filter matching zero tests in a module is an error there (exit 8), not an empty success.
CI excluded
Orkeon.Tools.Embeddings.Local.Testsby name from a solution-wide filter, which under MTP means "load that module, match nothing, fail". The exclusion is gone: measurement showed the SIGSEGV that motivated it comes from that assembly's 8Category=Slowtests — the ones that boot the real ONNX model — and from those alone (3/3 crash with only them, 5/5 clean without them). Its other 28 tests now run with everyone else's instead of being skipped, and the 8 get a step whose guard reads the runner's own accounting: the crash is tolerated only when every discovered test was accounted for and none failed, so a run that dies mid-suite stays red. The previous guard accepted that case. - The nightly needs the opposite tolerance. Selecting one category across the whole solution
leaves most modules empty by construction, so it passes
--ignore-exit-code 8— plus an explicit check that the run executed something, because tolerating "zero tests" per module must not let "zero tests anywhere" look green. Its VSTest-only--loggerargument is gone. - Coverage was measured by a collector the runner does not implement.
--collect:"XPlat Code Coverage"is refused outright (exit 5),scripts/sonar-analyze.*swallowed the failure with|| log_warn, and the analysis went on to import a report nothing had written — a Quality Gate evaluating coverage conditions against 0% and reading as a measurement. Collection moves todotnet-coverage(Cobertura → SonarQube generic via ReportGenerator, in both the shell and PowerShell scripts), and a report holding no class now aborts the analysis instead of being imported.
One flaky test surfaced with the new scheduler and is fixed rather than tolerated:
OtelTests.llm_call_emits_a_span_with_method_and_prompt_length_tags took the first LLM-call span
it saw from a process-global ActivityListener, so under a parallel run it asserted on a
concurrent act() loop's span. Its three neighbours already discriminated by a unique tag; it now
does too.
Fixed — BM25: the guard the code index never had, and two comments that promised too much
Bm25CodeIndex (Analysis) accepted any k1/b through its public constructor. A negative k1
inverts the term-frequency saturation and a b outside [0, 1] turns length normalization into
an unbounded multiplier: the index kept answering, with scores no caller could interpret and no
error to notice. It now validates exactly as its prose twin Bm25Index (Rag) always did.
The two implementations' doc comments claimed they were kept in step — "same k1/b defaults",
"Same k, same semantics". Nothing enforced it, and on the fusion constant it was already false:
the RAG side is operator-tunable through Orkeon:Rag:Retrieval:Hybrid:RrfK while code search
exposes no such knob. The comments now say what is true — the values come from the literature, the
two indexes never score the same corpus, and retuning one must not propagate. ADR-009 records why
these are not satellite candidates: a shared spelling is not a shared value.
The one real duplication there was internal to RAG. ReciprocalRankFusion.DefaultK and
RagDefaults.RrfK were two declarations of the same product default, backing two option classes
bound to the same configuration section (Orkeon:Rag:Retrieval:Hybrid) from two projects — so
changing the default would have moved one fusion and not the other. Orkeon.Rag already
references the Domain that holds it; the copy is now a reference, and the published value is
unchanged.
Added — satellites for the constants two projects must agree on (ADR-009)
Orkeon.Studio.Core may not reference Orkeon.Infrastructure or Orkeon.Hosting, and the
reason is measured rather than doctrinal: doing so "dragged the whole runtime — ONNX runtimes,
tree-sitter grammars, the local embedding model — into every published app, for roughly 230 MB
each". So Studio copied the values it needed by hand, and ConstantDriftTests existed to stop
the copies diverging.
Five new packages hold them instead, each with no runtime dependency, which is what lets
both sides reference one declaration: Orkeon.Constants.Llm (provider endpoints, default models,
provider keys), Orkeon.Constants.FileSystem (the virtual roots a runner mounts for itself, and
the conventional file names one component writes and another looks for),
Orkeon.Constants.Configuration (Orkeon:* keys and shared operator wording),
Orkeon.Constants.Protocol (the run event kinds a runner emits and Studio reads) and
Orkeon.Constants.Cli (the run option names the runners accept and Studio predicts).
Two of those closed drift that had already happened rather than drift that might: Studio's copy of the run event vocabulary was missing four kinds the runner emits, and an unknown kind is ignored rather than reported — so tool activity and delegations simply never reached the screen.
Orkeon.Domain gains its first runtime project reference, to Orkeon.Constants.Llm — its
default model name is the same string as OpenAI's provider default. A project that depends on
nothing inverts no layer and drags nothing in behind it; ADR-009 records the argument.
Nothing published changes shape: LlmEndpoints, ProviderDefaults and LlmDefaults are frozen
surfaces, so their names stay and only their value moves — a const initialised from another
assembly's const is inlined at compile time. Only RunnerMounts, which was unshipped, is
removed; its four roots are now RunnerVirtualRoots.
ConstantDriftTests goes from 12 facts to 5. The five that remain never guarded duplication:
they ask the endpoint detector to recognise each endpoint, pin the Docker Model Runner defaults
against the committed appsettings.json template, and check the declared minimum CLI version.
Publication order matters now: .github/workflows/publish.yml pushes the satellites before
Orkeon.Domain, or Domain would ship with a dependency that cannot be restored.
Changed — build toolchain
- Roslyn pinned to 5.9.0 repo-wide. The analyzer and the source generator reference
Microsoft.CodeAnalysis5.9.0, and a Roslyn component that references a newer compiler than the one running it is silently refused — a warning (CS9057), not an error, after which analysis and generation simply do not happen.Microsoft.Net.Compilers.Toolsetnow replaces the SDK'scscso both are actually loaded. It isPrivateAssets="all", so it never flows into a package; a consumer on an older SDK still gets the CS9057 behaviour, which is why the components declare the floor they need rather than the newest compiler available. - xunit v3 → v4. Test projects move to Microsoft.Testing.Platform.
dotnet testin its VSTest mode no longer drives them. - Jint interop pinned to
ArrayConversionMode.Copy. Jint 4.14 changed the default for CLR arrays toLiveView, under whichArray.isArrayon a returned array is false. The scripting DSL's typings promisereadonly number[][]fromllm.embed(), so a script branching onArray.isArraysilently took the wrong path. The pin restores the behaviour the typings describe. Note the scope honestly: this governs CLR arrays, so a binding returningIReadOnlyList<T>is host-wrapped under either mode andArray.isArraystays false for it.
Fixed — a team associates a folder, it does not declare one
In Orkeon Studio, « Autoriser un dossier » on a team opened the folder picker: a disk tree, a physical path, a rights choice. That is the declaration screen, and it belongs to « Réglages › Dossiers autorisés » — nowhere else. Wired onto the two team screens, it made every team re-declare its mounts from scratch, next to a settings list that already held them.
The defect was two lines of shell wiring, not the picker: MainWindowViewModel
had all three callers subscribed to the same modal. The two team gestures — the
creation wizard's « Dossiers de cette équipe » block, and « Autoriser un autre
dossier… » on an adopted team — now open a chooser over the folders the
settings declare. Ticked entries are carried over verbatim, rights included:
the settings are the one place a folder and its rights are decided, and a team
able to widen them would make that declaration a suggestion. A folder the team
already carries, or whose virtual root another folder already spends, says so
and cannot be picked — two mounts on one root is not a merge the runtime
performs, it is one it drops.
Declaring stays the settings' gesture, and « Déclarer un nouveau dossier… » is one door to it: the modal closes and the app lands on « Réglages › Dossiers autorisés », on that tab and not merely on that screen. One door, so a folder cannot be declared from two places and drift between them.
A team folder the settings do not declare now reads red — on the wizard's
chips, the "Mes équipes" cards and the team-mounts modal alike. It is not an
error: a team's /output and /input are created inside the team at adoption
and are never declared. It is the one thing a row cannot say by naming a virtual
path, and a team reaching outside the machine's authorized folders should not
have to be discovered by reading a sidecar.
A team reaching outside the settings no longer launches. « Exécuter » refuses a
team carrying a folder that no settings entry allows: the run button is disabled
and the card names the folders and the two ways out, with a button onto
« Réglages › Dossiers autorisés ». Discovering that refusal from a run that
failed halfway, its reason buried in a log, is what this replaces. The rule lives
in Orkeon.Studio.Core (DeclaredMounts.BlockingFolders), not in the WPF
screens, so the TUI launcher cannot answer it differently. A team's own /output
and /input never block it: they are created inside the team at adoption and are
its own plumbing, and counting them would make every adopted team unlaunchable.
The folders the blueprint implies became removable like any other. They were
informative chips with no ✕ — "edit an agent to change them" — which left a team
carrying a root its owner did not want with no way to say so. Dropping one now
sticks: WithDerivedWriteMounts no longer re-adds it, the same silent undo that
method exists to prevent. The screen warns and names the dropped roots, because
nothing will be bound to them and the agents writing there will fail; a single
« Rétablir » is the way back from a wrong ✕.
Fixed — Virtual paths are the only currency agents are paid in (ADR-008)
The owner found absolute disk folders in Orkeon Studio where only VFS mount points belong. The leak was in the engine, not the UI.
Runners used to mount their own directories 1:1 (C:\x:C:\x:ro) so the
absolute paths framework code had already computed resolved unchanged, and
FileSystemMount.IsValidVirtualPath had been widened to accept a Windows drive
path as a virtual path to let those strings parse. Mounts parsed from a mount
string are agent-facing, so list_mounts, ShellCommandTool's path checks and
every access-denied message — which names the available mounts — handed agents
the operator's disk layout; redaction was explicitly disarmed for exactly those
mounts. The crew directory is now /crew, a hosted daemon's crews /crews,
/crews-1, …, and the loader receives the virtual spelling, so it asks the VFS
whether its target is a directory instead of probing the disk.
Orkeon:FileSystem:InternalMounts is new: mounts registered with
MountVisibility.Internal — reachable by the VFS, absent from
GetAvailableMounts(). The --llm-log directory moves there; it holds full
prompts and API payloads and was being advertised to every agent as a writable
mount. As a result, turning --llm-log on no longer shifts
Orkeon:FileSystem:Mounts:{i}.
A denial message is read by the LLM, so it is an agent-facing surface like
list_mounts: FileSystemRegistry now builds its "Available mounts" and
"Mounts granting Write" lists from the agent-facing mounts only. It used to
enumerate every mount, which named /llm-logs to any agent that touched an
unmounted path — and annotated it (writable).
In Studio, the agent editor's « Sur quel dossier » line and the Composer's
folder chips now name mounts the way agents address them (/output (lecture, écriture)) instead of joining raw physical:virtual:rights strings. Expert
surfaces — the effective-mounts table, the picker's preview — still show the
exact command line.
An adopted team now writes where its own agents write. The trial bench
mounts /output and refuses a run that did not produce its promised
deliverable; nothing carried that mount further, so the same team launched from
its own folder was denied /output, logged a warning and reported success with
nothing written. forge promote derives the write roots from the blueprint,
creates their folders, and spells them in both launchers; Studio binds the same
roots into the sidecar at adoption. A deliverable root the runner reserves —
/crew, /script, /llm-logs — and the traversal spellings /.. and /. are
skipped rather than mounted.
And the promoted launchers now run from the team's own folder. Neither
run.sh nor run.cmd changed directory, while both address the crew by an
absolute anchored path: the runner refuses to read a crew outside the working
directory without --allow-external-mounts, and the security whitelist is
rooted on the working directory too. Launched from anywhere else — which is
every scheduled run the generated schedule/ artifacts install, since a service
starts in the system directory — the team was refused outright, or ran and wrote
nothing. Both launchers now cd into their folder first. In the same file,
--var was spelled once per sample variable, which the CLI's parser rejects as
a repeated option: several values now go space-separated after one flag, the
rule --mount already followed.
Breaking: --mount no longer accepts a drive-letter virtual path, and a
--mount claiming a root a runner reserves for itself — /crew and
/llm-logs on the YAML path, /script and /llm-logs on the scripting path,
/crews* on the orkeon-host daemon (exit 78) — is refused with an actionable
line.
RunnerHost.Build's llmLogPath parameter becomes llmLogVirtualPath and
takes a virtual path; a new optional internalMounts parameter follows it.
RunnerExecution.LoadCrewAsync keeps its signature but changes contract: its
configPath is now a virtual path, so a host passing a physical one is
denied by the VFS instead of loading. RunnerExecution.EnsureReservedRootsAreFree
is public, for hosts that inject mounts of their own.
A mount string can quote its physical path. Three call sites split a spec
on : with three different heuristics, and the one in
CliWorkspaceMountBootstrapper had none: on Windows it read
C:\src:/workspace:ro as the physical path "C", resolved it against the
working directory and emitted a corrupt mount string. The split now lives once,
in the domain type — FileSystemMount.TryGetBasePath, WithBasePath and
Quote are new, and both duplicate heuristics are gone.
A path the bare form cannot carry — one holding a : or a ;, or ending with a
backslash — is quoted: "/data/odd:name":/data:ro, "C:\src\":/workspace:ro.
Quoting rather than backslash-escaping, because a backslash escape would collide
with the Windows path separator, which is exactly what has to survive here.
Backslashes are ordinary characters, so every existing mount string is
unchanged, and two folders that had no spelling at all now have one: a drive
root ("C:\":/workspace:ro) and any path ending in a separator. Those quotes
belong to the mount grammar, so a shell must be told to leave them alone — the
CLI reference spells the bash and PowerShell forms.
Every producer goes through FileSystemMount.Quote, not just the parsers
through the split: the runners' own crew / script / exchange-log mounts, the
daemon's crew mounts, the two orkeon-repl bootstrappers and Studio's
MountDefinition.ToMountString. A folder whose name holds a ; is legal on
every OS Studio's picker browses, and each of those sites used to emit a spec
its own parser then refused.
And an adopted team can be launched from its own card again. Studio's target
detector looked for a crew definition at the root of the folder it was handed,
while forge promote keeps it in crew/ — so « Lancer » on a team card
answered "holds no crew definition… pick a file inside it instead", and
picking crew/ by hand found no sidecar beside it, so the team's mounts never
reached the command line. The detector now descends into crew/: the run path
goes to the definition, the selected path stays the team folder, which is what
puts the sidecar in view and the team's own /output inside the security root.
Two more Studio screens stopped showing folders: the « Importer une équipe »
recognition report joined the sidecar's raw mount strings — read by whoever
received the team, so it disclosed the exporter's disk layout — and the
team-mounts modal fell back to the raw string in a field documented as "the
virtual spelling the agents see". All four renderers now share one
MountLabels.
Also fixed: two hosted crews sharing a Name resolved to different definitions
(the plan kept the last, CrewHostRegistry.Find answers with the first);
examples/service-host/appsettings.host.json declared its mounts as objects, a
shape FileSystemOptions.Mounts cannot bind; the RaggableTree pages documented
a mount syntax that does not exist; and the --mount / --var help text said
"Repeatable." of options the parser refuses to see twice.
Fixed — the VFS boundary, asked the same question everywhere
A sweep over the code the ADR-008 diff did not touch, looking for the same shapes it had just corrected.
One containment predicate. "Is this path inside that directory?" was
answered in three places with three rules. PathValidator's was boundary-safe;
the runners' --allow-external-mounts guard was a bare StartsWith, and the
two disagreed exactly where it hurts: a crew in a sibling folder whose name
extends the working directory's (~/proj vs ~/proj-old) read as inside, so
the opt-in was never demanded, its base path never whitelisted, and the
boundary-safe validator then refused every file the crew touched — never naming
the flag that would have fixed it. PhysicalPathContainment now holds the rule,
including the part about case: Windows paths are the same path in any casing.
ToVirtualPath answered with the wrong mount. The registry orders its
mounts by virtual path length, which is what the virtual→physical direction
needs; coming back, it returned the first mount whose base path matched. With
nested mounts the two orderings disagree, and a file was handed back under a
parent mount's spelling — a name that re-resolves with different rights. The
most specific physical base now wins.
/sandbox existed in one host out of all of them. The mount the code
sandboxes write under was provisioned by an IHostedService, and the runners
build a host they never start — so every shipped CLI registered ICodeSandbox,
DockerSandbox and SecureCodeInterpreterTool over a virtual root that did not
exist. It is now built with the registry, in every host, started or not.
Breaking: AddSandboxMount is removed; AddOrkeonFileSystem does it.
shell_command could not use its own default. working_directory declared
"." — and since ADR-008 a virtual path starts with /, so no registry can
resolve it and every call that omitted the field, the shape a model writes for
an optional one, was refused. It now runs in the first readable mount, or in the
parent's directory when nothing is mounted.
Every promoted team was dead on arrival on Windows. The launcher built its
--mount by concatenation, so the physical segment inherited whatever the
anchor expanded to — and %~dp0 is always C:\…. Four segments, a grammar
error naming a path the user never typed. The generated spec now carries the
grammar's own quotes, and a test runs a real shell against a folder whose name
holds the separator. The forge trial bench and Studio's mount prediction went
through the same concatenation; both now go through FileSystemMount.Quote.
The daemon read its crew list from a file it never told anyone about.
orkeon-host resolved a relative appsettings.json against the executable's
directory, while --help promises ./appsettings.json and the host built
moments later reads the working directory — so the mounts came from one file and
everything else from another. It also accepted a malformed --mount at startup,
logged READY, and then failed every message; that is now a configuration error
with exit 78, and --help states the grammar the parser enforces (three
segments, ro|rw|rwnd, quoting).
--allow-external-mounts overwrote the operator's whitelist. It wrote
PathSecurity:AdditionalAllowedDirectories:0, replacing the entry an
appsettings.json declares there, while its own documentation says it
additionally whitelists. It now appends.
Fixed — configuration that decided nothing
process: parallel now honours dependencies:. The mode ignored them: every
task started at once, so a final synthesis task ran against an empty context and
reported success on the nothing it had. The documentation said to use Sequential
or Graph instead — and 23 of the 30 shipped parallel examples declare
dependencies anyway, which is the clearest possible statement of what the mode
is for. Tasks are now grouped into waves: everything whose dependencies are
satisfied runs concurrently, the next wave starts when they are done and reads
their outputs. A crew declaring no dependency is one wave — the previous
behaviour, unchanged. A cycle is refused, naming the tasks caught in it.
AgentSelectionStrategy now selects something. The option, its two real
strategies, StrategyAgentSelectionService and their DI wiring all existed —
and IAgentSelectionService.SelectBestAgentAsync had no caller anywhere in
src/, so setting Embedding changed nothing at runtime. TaskAgentSelector is
that call site: consulted for a task declaring no agent:, never overriding one
that does, degrading to round-robin with a warning when the embedding backend
fails. FirstFit — the default — keeps round-robin exactly as before.
An unknown process: is refused instead of guessed. A hand-rolled switch
fell through to Sequential, so process: graf ran a pipeline the author never
asked for. Both the YAML and the scripting paths now use ProcessType.TryFrom
and name the valid values. Breaking: a crew file with a typo'd process:
now fails to load instead of running as Sequential.
A Graph or Autonomous crew is no longer reported as Sequential. Three
hand-written MapProcessType switches restated the six-mode value object with
four arms each and mapped every other mode to "Sequential". They are gone; the
DTOs carry the value object's own spelling.
LogStreamingExchanges is honoured. It was bound from configuration, offered
as a checkbox in both Studio surfaces, pinned by Studio's settings validator —
and read by no code, so turning it off still captured every streaming exchange.
Removed: OrkeonConfig and OrkeonFeatureFlags. Four documentation pages
presented them as the framework's predefined configurations (Default,
Development, Production); no production code read either, and no DI entry
point accepted one. A host composes its settings through
AddOrkeonInfrastructure / AddOrkeonApplication and its appsettings.json.
Also: the five DLP interceptors' summaries read as descriptions of what the
framework does ("ensure PII never appears in logs") when nothing invokes them —
they are an opt-in toolkit a host applies, as
docs/reference/opt-in-subsystems.md already said, and each class now says so
too. And asyncExecution: is documented for what it is: recorded on the task,
honoured by no orchestration mode.
Fixed — Studio, and the team it hands over
An adopted team can now read, too. The trial bench mounts /workspace and
the Composer shows the chip; nothing carried it into adoption, so a team whose
agents use file_read passed its trial and could then read nothing — the exact
mirror of the missing /output. forge promote and Studio now bind it to an
input/ folder inside the team, and FORGE.md says to drop the readable
files there. Not the team's own root: --with-settings puts an
appsettings.json holding API keys at that root, and a read mount over it would
hand them to any agent with a file tool.
One chip per virtual root. A root the user allowed a folder for was rendered
twice — once as a removable chip, once as an informative derived one — and
removing the removable one changed nothing at save, because the derived binding
silently took its place. The derived list now shows only the roots no explicit
choice claims, so removing a chip brings the derived one visibly back: the
screen says what the save will do. The DerivedMounts documentation claimed
"the sidecar never records them", which was false and was the root of the
confusion.
Studio's TUI launcher started the CLI in the wrong folder. It derived the
working directory from RunPath, correct only while that path's parent was the
folder the user picked — and the ADR-008 detector change made RunPath descend
into a promoted team's crew/. The fix had landed in the WPF launcher alone.
Both now read RunTarget.WorkingDirectory.
Studio reported "custom" for Gemini — the endpoint its own preset catalogue writes. The drift test guarding the pair asserted the constant had been copied, not that the detector recognised it; it now asks the detector.
Studio's blueprint→mount derivation gained the guards the CLI's copy has. A
deliverable naming a reserved root (/crew, /script, /llm-logs) or a
traversal segment reached an adopted team's sidecar through Studio and nowhere
else, producing a team Studio could launch and the runner refused at start.
ForgeDerivedMountTests pins the pair.
Fixed — an internal mount is a boundary now, not a hiding place
ADR-008 introduced MountVisibility.Internal and said the limitation out loud:
the mount is withheld from every listing and stays resolvable, so an agent
that knows the name can address it. The names are documented. /llm-logs holds
every prompt and every API response of the run — one
file_read /llm-logs/llm-exchanges-….jsonl was the whole exchange history.
FileSystemRegistry now refuses an Internal mount by default, in both
directions: ToVirtualPath will not name one either, so a tool's
physical→virtual output rewrite cannot leak it. A refused internal mount is
reported exactly like a path that does not exist, and a nested one does not fall
back to its agent-facing parent.
The two components that legitimately write to an internal root — the LLM
exchange logger and the code sandboxes — ask for the new
PrivilegedFileSystemAccess by name. A distinct DI registration rather than a
flag on the interface everyone already holds: a tool cannot obtain it by
accident, and every holder is findable by searching for the type. A host that
wires its own IFileSystemService instead of calling AddOrkeonFileSystem
keeps exactly the behaviour it had.
Breaking: FileSystemRegistry.ResolveAndCheckRights and ToVirtualPath
take an optional includeInternal (default false), and FileSystemService's
constructor an optional internalAccess (default false).
Fixed — what the tests were not asking
The shipped scripting typings did not parse. tools.d.ts declared an index
signature as const [name: string]: …, which a TypeScript namespace cannot
carry and which is not a declaration at all — so the tools namespace this
package advertises was unavailable to every editor that loaded it. The only
guard over the typings asserted that certain substrings were present in the
rolled-up bundle, which cannot fail for that. Every .d.ts is now handed to
esbuild, the same front end that reads user scripts. llm.d.ts went with it:
it typed llm.openai/anthropic/ollama/azureOpenai/groq as non-callable
objects with a name property, while the runtime exposes them as factories
returning a config whose field is provider — every script typed against those
declarations got an error on the correct code.
docker build . failed at restore. The Dockerfile restated
Orkeon.ConsoleApp's project graph as a hand-written COPY list that had drifted
to 10 of its 22 projects, plus one it no longer references. The list is gone;
the graph is read from the tree.
Two runs started in the same second shared one exchange-log file. The run id was a UTC timestamp truncated to the second, against a class claiming "each logger instance creates a unique file scoped to that run" — and its per-file lock serializes writers inside one process, never across two.
The embedding port blew up at startup instead of saying what was missing.
AddOrkeonVectorSearch — which AddOrkeonInfrastructure(configuration) calls
unconditionally — registered OpenAIEmbeddingProvider by type, and its
constructor needs an M.E.AI IEmbeddingGenerator<string, Embedding<float>> that
only a host choosing local embeddings ever registers. MS.DI throws when a
registered service's own dependencies cannot be resolved, so
GetService<OpenAIEmbeddingProvider>() threw rather than returning null — which
put both graceful fallbacks (this one and
DefaultEmbeddingProviderResolver's) behind an exception naming an interface no
operator has heard of, at container build rather than at first embed.
UnconfiguredEmbeddingProvider exists precisely to give an actionable message
deferred to first use; it is now reachable. The provider is built from the
generator when one is present and absent otherwise.
orkeon doctor's onnx-reranker check now looks. It was a hard-coded ok
with a hard-coded detail, on the reasoning that the weights are embedded
resources of a package the CLI always references — true, and still not a check:
a trimmed publish or a renamed resource leaves the reranker broken and the
doctor cheerful. It opens the streams and reports the size.
Removed: ImageHelper (Orkeon.Tools.Abstractions) — no production caller,
and where it disagreed with the live ContentConverter it was the wrong one:
it declared SVG a supported image type, which no vision API accepts.
CliFileSystemService (orkeon) — no production instantiation, a
ResolveAndValidate that ignored its requiredRight argument entirely, and
prefix compares with no separator boundary.
Also: AgentMapper hardcoded Status = "Active" while the two handlers that
actually map an agent read agent.Status and never called it — it reports the
real status now, and the handlers go through it. The Orkeon.Tools.FileSystem
layering guard was a denylist of two names under a doc describing an allowlist
that was already false; it is an allowlist. Four *_ShouldHandleEvent tests
whose only assertion was Assert.True(true) are gone — their siblings assert
what the handlers produce. And the forge sandbox's stated write boundary now
matches its mounts (the session directory, not the /output folder inside it),
asyncExecution: and the crew rag: block say what they do, and the CLI
reference carries the caveat both getting-started pages already had about
orkeon run <dir>/crew.
Fixed — the adversarial pass, turned on the sweep's own work
The sweep that produced the sections above was reviewed by agents briefed to
refute it rather than confirm it. The deletions held: nothing removed was
reachable, and the database security policy the removal was accused of dropping
was in fact the weaker of two copies — the surviving
Orkeon.Tools.Data.Relational.DefaultDatabaseSecurityPolicy blocks stacked DDL
after a benign SELECT, neutralises comment prefixes and gates UPDATE without
a WHERE, none of which the deleted one did. What did not hold was the work
around the deletions.
An encrypted memory item kept its content and lost its identity. The
decorator rebuilt every item through MemoryItem.Create, which mints a fresh
MemoryItemId and stamps CreatedAt = UtcNow, AccessCount = 0,
LastAccessedAt = null — fields no caller can pass. SqliteMemoryRecord
restores exactly those from storage on purpose; wrapping that provider in
EncryptedMemoryProviderDecorator threw the work away again, so an encrypted
long-term memory reported the moment it was decrypted as its creation time and
never accumulated an access count. Ageing and recency-ordering read wrong
values, and only with encryption switched on. The rebuild goes through
MemoryItem.Restore now, which carries identity and metadata over whole rather
than enumerating fields that can be forgotten. Consolidating the six inline
rebuilds into one place had fixed the two dropped metadata fields and asserted
completeness without checking identity.
Removed: FeatureFlags (Orkeon.Application.Configuration). Its twin
OrkeonFeatureFlags was removed above for having no production reader; this one
sat in the same folder with the same profile, and was the more misleading of the
two — ShouldUseForAgent / ShouldUseForCrew implement hash-bucketed gradual
rollout over TrafficPercentage, so its public surface offers to canary a
percentage of crews onto UseSequentialCrewOrchestrator. Nothing called it. Its
only consumer was its own test file.
Removed (breaking, packages): Orkeon.Application.Abstractions.Data
(DatabaseQueryOptions, IDatabaseProviderFactory, IDatabaseSecurityPolicy)
and Orkeon.Infrastructure.Data (DatabaseProviderFactory,
DefaultDatabaseSecurityPolicy) — recorded in the public-API files but not
here. The migration is a namespace change, not a rewrite: the surviving types
carry the same names under Orkeon.Tools.Abstractions.Data and
Orkeon.Tools.Data.Relational, so a consumer sees "type or namespace not found"
and needs one using changed. docs/architecture/security.md names the
namespace now instead of the bare interface.
Orkeon.Infrastructure no longer drags in two database drivers it does not
use. Microsoft.Data.SqlClient (with its Azure.Identity /
Microsoft.Identity.Client chain) and MySqlConnector were referenced for the
removed DatabaseProviderFactory alone. Orkeon.Tools.Data references them and
is where they belong; every consumer of the Infrastructure package was carrying
their restore weight and CVE surface for code that no longer exists. Npgsql
stays — Checkpointing/PostgresStateStore uses it.
A promoted team could not write the deliverable it was built to produce. The
read mount is derived first, so a deliverable landing under /workspace met a
read-only entry and was skipped on the name alone: the launcher spelled
/workspace:ro, the deliverable resolver logged a warning, and the run reported
that it had finished. Studio's sibling derivation deduped the other way and left
a read-only /workspace beside a read-write one — two chips for one root, the
one promising a write being the one silently dropped. Both hold the same rule
now: one root, one mount, and a write requirement wins over a read one.
A deliverable folder named with a : or a ; produced a launcher that died at
every start. Those are the mount grammar's own separators — the launcher quotes
the physical segment and spells the virtual one bare — so --mount "…/rapports:2026":/rapports:2026:rw reached FileSystemMount.Parse as four
parts, after ForgePromoter had already created the folder, so the team looked
complete. ForgeBlueprint.Validate refuses the root at submit time, where a
repair turn can rename the folder, and the derivation refuses it again.
FORGE.md recommended the command the docs warn about. The card said "the
folder is ordinary: orkeon run <dir>/crew launches it too" — which is true, and
launches it without the --mount arguments the launchers supply, so a team
with deliverables writes nothing and reports success. Both getting-started pages
and the CLI reference carry that caveat; the card is what the colleague receiving
the folder reads, and it was the last surface still giving the bare command.
The launcher followed a symlink to the wrong folder. dirname "$0" on a
symlink gives the link's directory, so symlinking "run this team" onto PATH —
normal for a folder the card calls ordinary — made the launcher mount ~/bin/output
and die naming folders the user never created. It resolves the link chain first,
with plain readlink rather than GNU's -f, and the cd now carries || exit 1.
An appsettings.json mount was silently dropped on every single run. The
runner writes its own mounts into Orkeon:FileSystem:Mounts:0, :1, … from an
in-memory source added last — which wins on an identical key. So index 0 did not
add a mount, it replaced the operator's first one; and cliMounts is never empty
in a real run, since the crew mount is inserted at index 0 before this code sees
it. Every tool touching that mount then failed "no mount found", with nothing
anywhere saying a mount had been dropped, and orkeon-host lost one per hosted
crew directory. The overwrite had already been found and fixed for the sibling
key PathSecurity:AdditionalAllowedDirectories — in the same pass that left it
standing here, and then copied its shape into the brand-new InternalMounts key.
All three append now, and the regression test asserts it on the merged registry
rather than on the mount strings, which is what the existing suites looked at.
docker build . did not build. src/Directory.Build.props imports the file
above it with an unconditional <Import>, so with the root Directory.Build.props
absent the expression evaluates to "" and MSBuild refuses it (MSB4020) —
restore tolerates the empty import, publish does not, which is why the layer
that fails is not the layer that looks wrong. Replacing the hand-written project
list with COPY src/ did not fix the Dockerfile, and nothing in CI builds this
file, so the claimed fix was never executed once. It is copied now, and the image
builds.
crew.process was validated where nothing could recover from it. Unknown
values are refused rather than silently becoming Sequential — but
ForgeBlueprint.Validate never checked the field, so blueprint_submit accepted
process: pipeline and answered "Blueprint submitted", and the throw landed in
ForgeBlueprintCompiler.Compile, called un-guarded from the validate stage, the
render stage and ValidateEditedBlueprint. None of them turns it into a
validation error, so it never reached the two-attempt repair loop built for
exactly this: the session died at the CLI boundary with the interview and
blueprint turns already paid for, and forge resume reloaded the same artifact
and died at the same point. The check now runs at submit time, where the model
can act on it.
An internal mount was a boundary in the virtual namespace only. The commit above
refuses the name /llm-logs; it refused nothing to the bytes. With the log
directory nested inside an agent-facing mount — the ordinary arrangement, since
--llm-log ./logs needs no --allow-external-mounts precisely because it stays
under the working directory, and the working directory is what gets mounted for
the agents — /workspace/logs/llm-exchanges-….jsonl returned the very file that
/llm-logs/llm-exchanges-….jsonl was refused for, and ToVirtualPath handed
that address out. Both directions enforce physical containment now. Its own test
suite mounted the two directories as siblings.
/sandbox was mounted in every host and usable in none. Resolving a virtual
path is two steps: the registry answers where, then IPathValidator answers
whether. Moving the sandbox mount into the registry fixed the first and left
the second denying it — the session directory lives under the temp directory
while the validator's workspace root defaults to the current one — so the first
call of every code execution kept failing, saying "Path is outside the allowed
workspace directory" instead of "No mount found". No flag rescued it:
--allow-external-mounts whitelists the CLI and internal mount lists, and the
sandbox root is in neither, because it is injected rather than configured. The
session root is registered as an allowed directory, and the test exercises the
real validator instead of stubbing it.
The sandbox janitor deleted directories it had not created. It swept every
subdirectory of EphemeralRoot older than the threshold, recursively, checking
neither the name nor whether the owning process was alive — and it now runs in
every process that builds a VFS rather than only in a started host. A directory's
mtime freezes once its direct children exist, so a daemon idle past the threshold
looked exactly like an orphan and a CLI invocation would delete its sandbox
mid-run; and since EphemeralRoot is a free-form string whose directory this
code creates, pointing it at an existing folder made the sweep a recursive delete
of user data. Both guards are in place. Runner flows also dispose their host now,
so the session directory goes at the end of the run rather than waiting for a
later sweep, and /sandbox joins the reserved virtual roots — a user --mount
claiming it was crashing a DI factory instead of printing the one-line refusal.
A stalled embedding endpoint killed the crew and blamed the user.
TaskAgentSelector rethrew every OperationCanceledException, but an HTTP
timeout inside the embedding backend surfaces as one too — and definitionally is
not the crew's token, since the adapter passes CancellationToken.None down. It
went straight past the degrade-to-round-robin path the class exists for, and the
terminal event reported a cancellation nobody requested. The guard is conditioned
on the caller's token.
The scripting typings did not compile, in a new way. tools.d.ts traded an
index signature a namespace cannot carry for a namespace tools beside a const tools, which do not merge (TS2300/TS2395); llm.d.ts introduced a second
LlmConfig colliding with the one in agent.d.ts (TS2687 on all six members,
TS2717 on model) — a net-new break in a file that pass never opened. Both
passed the new typings test, because it runs esbuild: a transpiler strips types
and reports neither. tools is one interface with one const now, LlmConfig
is declared once, and the suite gained a check for the duplicate-declaration
shapes a transpiler structurally cannot see. The removed agent.d.ts copy also
documented a literal the runtime discards — ExtractLlmConfig returns null for
anything that is not a JsLlmConfig.
The inbound process-type map still collapsed unlisted modes into Sequential,
and the DTO enum stopped four modes short of the six the domain carries, so a
crew created through CrewMapper could not be Graph or Autonomous at all and
asking for one produced a Sequential crew that ran to completion. The enum
carries all six; an out-of-range value is an argument error. The test that
asserted the fallback asserted it by name.
A command that is not installed put a disk path in front of the model.
ShellCommandTool now hands ProcessStartInfo a resolved physical working
directory, and process.Start() sat outside the outbound rewrite — so any
allowlisted-but-missing binary returned "…with working directory '/tmp/…'" to
the LLM. The start is redacted like every other outbound string.
Two containment copies survived the pass that claimed to unify them. The
registry's own anti-traversal check — inside the very method the boundary suite
exercises — and both guards in Studio's TeamCatalog hardcoded Ordinal and
knew nothing of AltDirectorySeparatorChar. All five call sites route through
PhysicalPathContainment now, and the type's own doc says five rather than
three.
The VFS exception table pointed at a file that was deleted in the same pass.
docs/architecture/vfs-compliance.md and its French mirror still listed
Scripting.Cli/CliFileSystemService.cs as a ratified permanent exception —
doubly wrong, since that file was never in the analyzer's allowlist to begin
with; it carried an inline suppression. The neighbouring bootstrap row was
updated for SandboxSession in the same edit, so the row was read and left.
1.0.0-rc.2 - 2026-08-25
Added — Remediation v3: what a run costs, and adoption that is no longer a one-way door
The owner's third design pass (RC2-FEAT-06) lands two engine-backed features and a conformity sweep of the wizard.
Usage metrics on the wire (W-08). The cache dimension joins the token
telemetry end to end: TokenUsage carries the prompt-cache hit/miss pair (a
partition of the prompt tokens, never an addition) with a computed hit ratio;
every orchestration strategy stamps tokens+cache on its per-task snapshots; the
CostUsageEvent sink channel carries the same pair. orkeon run's
run.finished now says what the run cost (durationMs, prompt/completion
split, cache pair), and the forge trial's run.finished and verdict.ready
carry the trial's own figures — distinct from the session-cumulative
cost.updated. Studio shows them as one chip recipe («12 840 tokens» ·
«cache 62 % · 7 980 tokens» · «59 s») on the verdict card, the launcher's finish
line and the history entries (tolerant schema). Not measured = no chip, never a
zero.
Modify, re-try, re-adopt (W-09). forge resume of a promoted session (or an
abandoned one that reached a verdict) reopens it at the arbitration — the stored
verdict re-announced first; a new retry decision re-runs the trial as-is (zero
compose tokens, one budget iteration, refused recoverably on an exhausted
budget); forge promote to the session's own promotedTo updates the team
folder in place (generated files regenerated, user files preserved, a dropped
schedule removed) — any other non-empty destination stays refused. In Studio,
« Modifier » on a team card reopens the wizard at Composer with the stepper
fully reachable and the adoption fields seeded from the sidecar; re-adoption is
pinned to the original folder; the saved card offers « Modifier l'équipe » and
« Refaire un essai ». The blueprint's crew.name must now be a short display
name (≤ 60 chars, repairable error) — it becomes the team's folder name.
Edit at the dry pause (W-10). forge resume <slug> --edit amends the
blueprint of a session paused before its trial: the amended JSON travels as the
channel's first inbound line, is validated in full (parse, compile, tool
catalogue), re-announced blueprint.ready on the current iteration — no
charge: that iteration's trial has not run yet — then re-rendered
deterministically; with --dry the session pauses again at the same boundary,
and an invalid edit leaves it exactly where it was (recoverable
FORGE-BLUEPRINT-INVALID). This wires Studio's « Modifier » on the Composer
step's agent cards, which was greyed out at the pause: the agent editor now
applies through a resume --edit --dry child run and the Composer repaints with
the amended team. At the arbitration, the edit decision remains the path.
Conformity (mock v3 volets): the stepper pills centre number and label; the
verdict buttons live in the verdict card (accept primary); one shared chip
recipe (ToolChip/ChipAction) across every tool, mount and metric chip; card
headers align title and mono meta; the « Définition générée » card shows the
rendered YAML itself with a Copy action; the Adopt step's model setting is a
profile card with unfoldable radio rows (no ComboBox); the Composer folder row
shows the mounts the agents imply (deliverable roots + the sandbox read mount).
Also fixed: a cold resume of the Composer pause left « Essayer l'équipe » dead
(the hydrator now restores the session identity from session.json).
True half-circle pills (W-11). WPF, unlike CSS, does not clamp
CornerRadius to half the element's height — the 999 reflex deformed every
chip's ends into ogives. Every pill now carries a fixed height with a radius of
exactly half of it: ToolChip 22/11, new MetricChip 20/10 (tokens, cache,
wall time), BadgeBase 20/10 (plus BadgeNeutral/BadgeSky variants), and no
chip border hard-codes a radius in a view any more. Action buttons are not
pills: ChipAction drops from 12.5 to the 10 cap. A conformity test sweeps the
XAML (no oversized radius anywhere) and pins radius == height/2 on the pill
styles.
Added — Remediation v2: team folders end to end, and the blueprint edited by hand
The owner's second design pass (RC2-FEAT-05) closes the gap between the v3 mock
and the app around one idea: a team's folders are part of the team. The
adopted team's sidecar now records its mounts; the "Mes équipes" cards show them
as chips with a three-tone badge (scheduled / to try / on demand), the agent
count, the last run from the launch history and the model setting; « Changer les
dossiers » opens the team-mounts modal, and every folder choice goes through the
shared « Autoriser un dossier » picker (path + browse, one-level tree, rights in
two radio rows, expert mount-string preview). Studio lays those mounts on each
launch as --mount arguments — the chips and the command cannot disagree.
The forge protocol's reserved edit arbitration is implemented: interactive
mode now arbitrates every verdict (accepting a conforming one costs one click),
and decision.made {edit} followed by blueprint.edited {blueprint} re-renders
deterministically — zero LLM tokens — then re-earns its verdict through the
unchanged validate/test/diagnose path. The wizard's Composer step shows one card
per blueprint agent (its own goal and tools) with « Modifier » opening the agent
editor (name ↔ role, one-sentence goal, togglable capability chips), plus
« Ajouter un agent » and the « Dossiers de cette équipe » block written into the
sidecar at adoption. A novice help rail explains each step in plain words.
Also: « Validation à blanc d'abord » on the Exécuter screen (a failed dry pass
stops the launch), the import report's folders row, the expert TRIAL COMMAND
card, « Copier le rapport » in the Diagnostic header with its « Copié ! »
feedback, the plain-words novice title on the checks card, the Limites cards'
descriptions under their titles, the export action on team cards (never copies
appsettings.json), and the removal of the settings validation card (the status
line reports instead).
Fixed — Kimi's server-mandated temperature self-heals instead of killing the run
Moonshot rejects some models' requests with 400 invalid temperature: only 1 is allowed for this model — and which models mandate it is decided server-side.
KimiLlmProvider now reads the constraint from the API's own rejection and
re-sends the request once with the mandated value, logging a structured
warning (never a silent substitution). The seam is a new overridable on
OpenAICompatibleProviderBase (TryAdaptRejectedPayload — one adaptive retry
on a 4xx, non-streaming paths only), available to any provider with a
server-stated constraint. Before this, a Studio wizard run on a Kimi profile
died at the brief stage after three identical rejections.
Changed — The WPF screens remediated against the v3 mock (audit T-01…T-13, screens 01–22)
A full-screen audit against the design mock (the mock is the source of truth)
drove a six-lot remediation of orkeon-studio:
- Foundations — the logo's rings rotate as one group (they were skewed by
double rotation origins); MODE and EN/FR are real segmented controls; implicit
styles for TextBox/ComboBox/CheckBox/RadioButton/ScrollBar erase the native
Windows chrome; every raw
CornerRadius="999"outside badges is bounded to height/2; the splash mascot PNG is re-flattened so its corners land exactly on the plate colour. The window holds a 1024×768 minimum. - Run — rebuilt as the mock's vertical cards: a sidecar-backed team card, a plain-language progress card (state title, tone badge, "Ouvrir le résultat" on the first writable mount) and a technical journal folded by default in novice; COMMANDE and the option rows are expert-only; a localized banner replaces the raw locator message when the CLI is missing.
- Language — every user-visible French string says « équipe » (pinned by a resx test); mode-dependent subtitles carry the mock's wording; validator findings and doctor checks show a per-code plain-language overlay first, the raw CLI-grade line staying as expert detail; the Limits cards are titled in plain language.
- Screens — History becomes a card list with per-run duration
(
LaunchHistoryEntry.DurationSeconds, tolerant migration) and per-card Replay/Open-result; Test is two columns with a full-height console and a sample-inputs field; Import gains a drop zone, a single Browse, a recognition report and an expert "Test it first" hop; the mounts form no longer opens in an error state and novice gets one card per folder plus an "Allow a folder" picker; Diagnostic opens on a verdict card fed by a silent first doctor run; the Create gate is a radio list. - Startup & secrets — the window opens at the mock's 1024×768 baseline
(still resizable up); the Réglages model tab gains an "API keys" card:
one row per environment variable the profiles resolve (status chip, paste
field, "Mémoriser la clé"), storing through the same
IApiKeyStoreas the profile editor — the value lands in a user environment variable and the field empties once stored; no key ever enters a file. - Polish — novice Settings auto-saves on every edit (Save/Validate stay expert; the validation card is expert-only, keeping the copyable report); profile cards carry "Utilisé par" team chips; Limits cards say when the engine's defaults apply; the raw-JSON viewer is bounded; the teams counter is a quiet right-aligned figure; History/Test/Run have empty-state phrases; team cards gain an "Ouvrir le dossier" explorer action.
Added — Every provider one click away, the API key novice-proof, and a screenshot campaign
The model-profile editor (design v3, "volets" rev. 2) now carries the full provider
catalogue: the two local runtimes (Ollama, Docker Model Runner — free, keyless), one
card per cloud the framework ships a provider for (OpenAI, Anthropic, DeepSeek, Mistral,
Gemini, Groq, Together AI, Qwen, Kimi, HuggingFace, Z.AI — endpoint, default model and
key variable pre-filled from the drift-pinned runtime constants; Azure OpenAI stays a
"Compatible OpenAI" entry by design), the OpenAI-compatible catch-all and the echo
fallback, grouped as the mock groups them. A novice clicks a card and pastes the API
key in the editor: "Mémoriser la clé" stores it in a user environment variable
(the vendor's conventional name — DEEPSEEK_API_KEY, ANTHROPIC_API_KEY, …), the
status chip says whether one is in place, the vendor's key console is named, and the
connection test refuses to probe into a guaranteed 401 when the key is missing. The
profile store only ever carries the name of the variable (ModelProfile.KeyEnvName
— the round-trip test forbids the ApiKey substring in the file as a tripwire);
launches resolve it and lay the value over the child process as ORKEON_Llm__ApiKey
(Run, Test, history replays, and the wizard's assistant alike).
orkeon-studio --capture-screens <dir> walks every screen in both modes (plus the
profile-editor and About overlays) and writes one PNG per stop — the
fidelity-remediation reference collection against the design mock, one command on a
Windows machine.
The Diagnostic screen gains "Copier le rapport": WPF text blocks are not
selectable, so the whole orkeon doctor result (verdict, every check verbatim, parse
error if any) is now one click away from the clipboard.
The Settings screen's validation card gains the same copy affordance as the diagnostic ("Copier le rapport"): summary line plus every finding with its severity, verbatim.
OrkeonBinaryLocator learns the development-checkout layout: when Studio runs
from its own bin/ inside a clone (detected by walking up to Orkeon.sln), it probes
src/scripting/Orkeon.Scripting.Cli/bin/<Configuration>/<tfm>/ — same configuration
first, the sibling second — so F5-from-the-IDE finds the CLI the repo just built
instead of reporting it missing. The not-found message now names the dev gesture
(dotnet build src/scripting/Orkeon.Scripting.Cli) — and the whole lookup is now
operator-steerable, in order: the --cli-dir <dir> argument (WPF app), next to
the executable, the ORKEON_CLI_DIR environment variable, PATH, then the
development checkout. The not-found message lists that exact order.
Changed — Full documentation audit against the implementation (DOC-04)
A five-domain adversarial audit (getting-started/README, architecture, RAG/RaggableTree/
EventHub/ADRs, reference, orchestration/guides) verified every factual claim in the docs
against the code and fixed ~140 confirmed discrepancies, in both languages. The heaviest
classes: phantom APIs (AddOrkeonRuntime, CodeSecurityAnalyzer/CodeSandbox/
SensitiveDataDetector/ComplianceChecker/RetryPolicy-family types, the
ValidateServerCertificate opt-out, manager_llm/autonomous_budget/state_timeout/
json_search/csv_search YAML keys, the raggableTree: crew-YAML section, the
Exp07CommandSurfaceTests suite, Orkeon.Examples.Runner); YAML samples teaching a
shape the loader silently loads as an empty crew (a crew: root wrapper and
sequence-form agents:/tasks: — the schema is flat-rooted with id-keyed mappings, now
taught correctly in process-types/autonomous/blueprint); stale capability matrices
(response-format and vision are capability-driven across the 13 providers — not
"DeepSeek only"/"Anthropic+OpenAI only"; Anthropic thinking is toggle, adaptive is
its wire value); wiring claims corrected to what composition roots actually do
(the AddOrkeonInfrastructure overload capability table, MCP being effectively
library-only, the empty InMemoryToolRegistry stub vs ServiceProviderToolRegistry
in both tool-authoring guides, StrictTools lenient library default, EventHub telemetry/
metrics marked designed-not-built, the autonomous YAML budget being Permissive not
Default); plus the settings-resolution chain, the complete orkeon run flag table,
yaml-schema.md gaining the six shipped blocks it omitted (llm: full surface,
links:, task tools:/deliverable:/llm_override:), [ToolContract] added to the
new-tool guide, VFS diagnostic help-link anchors, and ADR-002/005 amendments recording
their drifted counts.
Four code fixes rode along: RunArgumentsBuilder now emits a single --mount/-V
flag with space-separated values (the CLI parser rejects a repeated option — multi-mount
and multi-variable Studio launches were broken); AddOrkeonApplication(Action<…>) no
longer silently drops AgentSelectionStrategy from the options copy; the three
examples/runners packables are re-aligned on 1.0.0-rc.2; and the plugins CA2000
suppression justification now states the real ownership contract.
Changed — The tool inventory rebuilt against the implementation, and gated (DOC-03)
docs/tools/inventory.md (and its French mirror) claimed 43 tool names out of the 79
built-in tool classes, listed two tools that do not exist (bing_search,
google_search), used registry names the code never had (delegate_work and
ask_question — the real names are delegate_work_to_coworker and
ask_question_to_coworker), and flatly denied XLSX support while xlsx_reader and
xlsx_writer ship in AddOrkeonDataTools(). The page is rebuilt as the reference
catalogue: every one of the 79 tools with its exact agent-visible name, class, owning
DI extension and secrets policy; an availability matrix per composition root
(orkeon run vs the REPL — they do not register the same suites); the complete
alphabetical YAML mapping; an "outside the catalogue" section (forge-internal tools,
McpToolAdapter, test doubles); and the counting rule stated so the number is true by
definition. Secondary surfaces follow: autonomous.md no longer implies spawn_agent
is wired (no shipped root registers it — now a documented limitation) and its YAML
example stops citing the nonexistent json_search/csv_search; bootstrap.md's suite
comments match what each extension actually registers; the porting guide and the
READMEs mention Office (DOCX & XLSX) explicitly.
scripts/check-doc-claims.py gains the gate that makes the regression class
impossible: it extracts every agent-visible tool name from the code (the
[ToolContract("…")] positional or the Name => "…" literal of each counted file) and
fails the build if a shipped tool is missing from the inventory (either language), if
the inventory tables a name no tool bears, or if the summary total drifts.
Changed — Orkeon Studio v3 "volets": the WPF app becomes a team-lifecycle product
The Claude Design v3 handoff is implemented end to end. The window's sidebar now follows the life of a team — Agent teams (Create a team, My teams, Import), Work (Test, Run, History), Environment (Settings, Diagnostic) — under a global Novice/Expert switch persisted with the theme and language: Novice explains each step and shows contextual help, Expert shows the machinery (command lines, raw JSON, technical journal, the Test screen). The window opens on a startup screen (Kama, the mascot — click to skip) and carries an About overlay; the guided tour is rewritten to the five v3 stops.
- Settings, unified: the former Start/Sections/Mounts/Raw screens fold into one
"Réglages" entry with four inner tabs (AI model, authorized folders, expert-only
limits & logs, expert-only raw file with its location and resolution chain). The model
tab introduces named model profiles (
studio-model-profiles.json, next tostudio-history.json): reusable settings, a default election mirrored into theLlmsection through the ordinary save cycle, per-profileORKEON_Llm__*environment overrides, and the profile Studio's own assistant runs on. The API key never enters the store. The preset picker retires; the profile editor offers the sameorkeon initcatalogue with a live connection probe. The Diagnostic sidebar entry gains a warning dot. - The creation wizard replaces the Atelier UI (breaking for the screen, not the
engine): "Créer une équipe" walks Décrire ▸ Composer ▸ Essayer ▸ Adopter over the same
orkeon forge --events jsonlchild — the stepper projects the engine's milestones, the per-step "consigne + questions" blocks travel downuser.message, arbitration buttons come fromdecision.needed's own options, "Fix and retry" carries the trial consigne, and the wizard is gated until the assistant has a model profile (handed to the engine as environment overrides).ForgeClient, the session model and the protocol are unchanged. - My teams: adoption promotes straight into
~/Orkeon/teams/<slug>with the engine's real schedule grammar (on demand,daily@HH:mm,hourly) and writes astudio-team.jsonsidecar (name, need, profile, displayed schedule — never a key). The sidecar's profile rides every launch of the team — Run, Test, and history replays — asORKEON_Llm__*environment overrides resolved against the profile store. Team folders are listed as cards (launch, duplicate, delete), next to the wizard sessions still underway, resumable where they stopped. - Import and Test: "Importer" recognizes a shared team with the launcher's own detector, warns loudly when a definition carries a pasted secret, and copies into the teams root only on confirmation, under a never-overwriting slug. The expert "Tester" screen runs blank-run validations and real trials over a dedicated launcher with no history store. In Novice, the run screen hides the options machinery entirely.
The two Terminal.Gui apps (orkeon-studio-config, orkeon-studio-run) keep their current
surface. Nothing is deployed, so the old WPF screens are removed without shims; the resx
pair moves to 395 keys per language, still pinned by the drift and parity tests.
Added — Studio staffs the client://studio seat: agent requests answered on screen
An agent's send_request to the watching client no longer dies of a 30-second timeout: the
Launch screen shows the request and a human answers it.
- Protocol:
hub.messagegains anexpectsReply: truefield, emitted only on an agent'ssend. The peer must not have to guess which correlated lines are questions — a topic relay can carry acorrelationIdtoo, and the answer to the peer's ownsendpairs by correlation as well. The field is additive; readers that ignore it lose nothing. - Studio Core:
RunProgressModelfolds marked sends into aPendingAgentRequestqueue (dedupe by correlation id, cleared atrun.finished,ReplyAccepted()mirroringAnswerAccepted()), alongside the untouchedHubMessagesjournal. - Studio WPF: a request panel — asking agent's hub address, raw JSON payload, reply box —
in the Launch screen's progress card. The typed reply goes down stdin as the
{"kind":"reply",…}line the bridge requires; text that parses as JSON travels as that JSON (an agent may await a shape), anything else as a plain JSON string. A refused write keeps the request on screen. Hub posts are now listed in a compact panel instead of being parsed and shown nowhere. Silence past the agent's own timeout remains a refusal — the screen just gives the human a chance to speak before it.
Added — Real Discord slash commands: /status and /stop, registered and gated
The host's two commands stop being text parsing and become what Discord users expect: registered slash commands, autocompleted by the client, answered ephemerally — a status poke or a refusal is the invoker's business, not one more line in everyone's thread.
- Registration at connect: globally when
Discord:GuildIdsis empty (zero configuration, Discord caches global commands up to ~1 h), or per named guild for immediate availability (the dev loop). A registration hiccup logs a warning and leaves the message channel alive — it never kills the daemon. An unparseable guild id refuses the start (exit 78). - The text parsing is removed (nothing is deployed, no dual path): the platform's client
intercepts the slash, and a literal
/stoparriving as plain message content is a prompt like any other. - Security fix along the way: the Stop button used to bypass the allow list entirely —
component.Userwas never read, so anyone who could see the thread could kill a run while typing/stopwas gated. Button and slash commands now converge on oneCommandInvocationpath checked againstAllowedUserIds; an unauthorized click gets an ephemeral refusal instead of a silent stop. - The privileged
MessageContentintent stays: runs are started by plain thread messages, which slash commands do not replace.
Added — The Atelier: orkeon forge, from a need in plain words to a deployable crew (FORGE-01→08)
The missing step between "I have a problem" and a running agent team. orkeon forge "summarize my supplier's new offers every morning" opens a short interview, captures a
structured brief — goal, inputs, acceptance criteria, a sample input — plans a team,
renders it as ordinary crew files, validates it, tries it in a sandbox on that sample,
and judges the result against the criteria the user stated. Not conforming? The diagnosis
feeds a refine loop, bounded by a hard three-dimension budget (iterations, tokens, wall
time). orkeon forge promote <slug> --to <dir> then ships the crew as an ordinary folder.
The spine is deterministic — a StateMachine<ForgeState, ForgeTrigger> with 11 states,
checkpointed after every step — and only schema-validated brief_submit /
blueprint_submit submissions advance it: the conversation is carried by an embedded crew
(the scripting DSL running under Jint, ctx.llm.act() reached natively), but it never
steers the cycle. Sessions live under .orkeon/forge/<slug>/ — resumable
(forge resume), diffable between attempts, auditable — and every run emits a versioned
JSONL event protocol (--events jsonl), golden-pinned on both sides of the wire.
- Two formats, one generation. The assistant produces a single schema-constrained
blueprint; two deterministic renderers derive from it — the per-entity YAML layout
(default) or an editable
crew.ork.ts(--format script, needs esbuild; absent, a new session falls back to YAML withFORGE-ESBUILD-MISSING). Both converge on the sameCrewDefinitionValidator, and the sandboxed try loads the crew from the rendered files — what was written is what runs. - The sandbox restricts by removing tools from the catalogue, not by hoping the model
abstains:
shell_commandandcode_interpreterare gone from the list that feeds both the blueprint prompt and the validation, and writes are confined to the session's/outputmount, snapshotted per run. - The verdict is recomputed, never trusted: score ≥ 0.7 and no blocking finding, with
a missed
mustcriterion blocking regardless of score. With no judge available, the verdict announces itself asdeterministicand leans on mechanical checks — it never invents a passing score. Accepting a non-conforming result on sight stays legitimate. - Promotion is honest about its limits: the folder carries
crew/,run.sh/run.cmdcomposed against the CLI's ownorkeon rungrammar with the sample inputs pre-filled, andFORGE.md— the crew's identity card, written in the interview's language. With--schedule daily@HH:mm|hourly, the Windows task XML, systemd timer and cron line are generated and the install command is displayed, never executed: Orkeon has no scheduler, and pretending otherwise would promise supervision it cannot give.
In Orkeon Studio, the same engine drives a new Solve screen (WPF): a conversation on
the left, one card at a time on the right — success criteria, the proposal in plain words,
the live try, the ✔/✘ checklist quoting those criteria verbatim, then "what now?". Studio
spawns orkeon forge --events jsonl as a child process and answers over stdin; it never
touches an LLM itself, and a capability absent from the event stream exists on no screen.
Orkeon.Studio.Core gains a Forge/ client (tolerant parser pinned against the CLI's
golden lines, session projection, catalogue, resume hydrator) without a single new
dependency. RunSession/RunLaunchRequest/TargetSelectionModel/LaunchOptionsModel were
promoted into Orkeon.Studio.Core.Launch along the way, so the launch lifecycle now exists
once instead of twice.
Docs: Forge a team from a need (EN/FR),
the orkeon forge section of the CLI reference, and examples/forge/promote-demo/ — a
bundled ready session whose list/promote half runs with no LLM at all.
Added — Watch a run, talk to it, host it: the event bus, the hub pipeline, the service host (BUS, HUB, GATE)
Three chantiers that share one goal: a crew you can see, answer and reach from outside the process it runs in.
orkeon run --events jsonl speaks a versioned protocol instead of printing for a person —
one JSON document per line out, one per line back. Task completions in all six orchestration
modes — terminal events included: a cancelled or failed run still says so, in every mode —
cost with model and provider (no invented price: the framework has no price table), generation
deltas under --stream, tool calls, delegations, and runtime agent spawns when a spawn_agent
tool is attached. Tool events come from a single decorator applied where tools enter the
process — the DI registrations, and through the IToolDecorator port the per-agent delegation
pair — so the agent loops and the scripting facade are covered at once, .ork.ts targets
included. Contract: The run event bus,
plus a ~90-line dependency-free reader in examples/run-events/ with a recorded stream to try
offline.
- Silence is not consent. Without the protocol, a task declared
humanInput: truewas auto-approved behind the user's back. Asking for the stream replaces that provider: the question goes out and the run waits. No answer — closed channel, cancelled run — is a refusal, never an approval. - Studio's Launch screen stopped being a terminal. It shows finished tasks, cost and the run's question on screen; the raw log is demoted, not removed. A silent run says "nothing reported yet" rather than implying progress, and a question with no correlation id is not shown as pending, because answering needs an address.
The EventHub's middleware pipeline ships complete: logging, telemetry, ACL, idempotency and
validation, on both the publish and the receive path. The links: YAML grammar lets a crew
declare who it may talk to, and client://{name} lets an external process be named — without
it, a watching peer would escape the ACL by simply not being modelled. Idempotency guards
point-to-point delivery only: deduplicating a topic message by identifier would starve every
subscriber but the first. Its memory does not survive the process, which is stated rather than
implied.
orkeon-host hosts crews as a daemon — systemd unit, Windows service or container, same
binary, and runnable in a terminal because a daemon you cannot run in the foreground is one you
cannot debug. One dependency-injection scope per run, which is what keeps one conversation's
memory from reaching another's. A Discord channel gives the first place people can reach a crew
from: allow-list authorization checked before routing, thread-is-run mapping, an immediate
acknowledgement carrying a Stop button, throttled progress, /status and /stop. An empty
allow list denies everyone and refuses to start.
It hosts crews; it does not schedule them. rc.2 ships no scheduler, and neither the docs nor the
systemd unit implies otherwise. Docs:
The service host and the chat gateway (EN/FR),
examples/service-host/, and deploy/ for the unit, the SCM script and the Dockerfile.
Changed — EventHub API surface, and the crew configuration
CrewLink and CrewLinkDirection live in Orkeon.Domain.EventHub: a link is what a crew
declares, not a transport detail — and it names its target by the crew's name:, the only
identity a YAML author has (ICrewLinkRegistry.Register records the id ↔ name mapping for every
crew, links or not). CrewConfiguration gained a nullable Links (never-declared and
declared-empty are different answers to the ACL). MailboxAddress gained the client://
scheme. Message.NoDeclaredSchemaId is "_none" — a sentinel that cannot collide with a real
schema id. CrewOutput gained Succeeded, because KickoffAsync never throws and a host has
to tell an answer from an apology. IMemoryService gained ReleaseMemorySystem, the
daemon-side counterpart of loading a crew per message. The forge stream shares the run envelope
and speaks protocol v2 (was v1); its reserved-name set grew from four to eight. Several
constructors gained an optional trailing parameter — the orchestration strategies
(ICrewExecutionHook), InMemoryEventHub (middlewares), CrewFactory (the link registry),
AgentDelegationToolsProvider (the tool decorator) and RunnerHost.Build (a builder hook).
Source-compatible; recompile.
Fixed — Two harness defects that were reporting success
TraceExplorerService listened to every process-wide Orkeon activity source, so a test
asserting "no traces" was really asserting that no other test emitted one — true by luck, and
less true as the suite grew. Trace capture is now scopeable via MonitoringOptions.TraceSourcePrefix.
The E2E CLI tests rebuilt the CLI on every invocation, and two classes doing that concurrently
made MSBuild write its diagnostics onto the stream under assertion. The CLI is now built once
per assembly and run with --no-build.
1.0.0-rc.1 - 2026-08-18
Orkeon's first release candidate — the version that goes to NuGet.org. Everything
accumulated since 0.9.2-beta ships here: the RAG subsystem with its five profiles and
the corrective CRAG graph, declared LLM capabilities across all 13 providers
(Google Gemini joining as the 13th), the dual-era MCP client and server, A2A task
persistence, Orkeon Studio with full localization, the Windows/Debian/macOS install
channels, the TypeScript CLI command layer, the complete API reference site — and a
mechanically frozen public API surface (29 235 declared APIs, [Experimental] markers
on the four unstable areas) with the versioning policy to match. Breaking changes below
are called out in their own entries (RAG namespace extraction, API-shape conformance,
Orkeon.Cli.Commands.Scripting rename).
Fixed — orkeon llm probe|models now know Gemini (PUB-15 leftover)
LlmProviderFactory accepted gemini/google since PUB-15, but the CLI's
LlmCatalogClient had no default base URL for it and did not treat it as
OpenAI-catalog-compatible — orkeon llm models -p gemini failed without an
explicit -u, and the probe help text stopped at 12 providers. Both wired;
surfaced by the DOC-02 review of the new CLI reference page.
Changed — Documentation trued up against the code, end to end (DOC-02)
A three-pass audit (docs/ tree, root/examples/OSS surface, facts vs code)
followed by full remediation. Highlights: the quality-gate page no longer
claims a blocking Sonar CI gate that does not exist; the fictional
autonomousBudget YAML block is marked not-implemented; two lifted
limitations rewritten (uniform streaming, runtime plugins); every count
trued (79 tool classes, 44 domain events, 13 providers, 33+33 projects);
17 dead maintainer-side references and 4 failing copy-paste commands fixed;
~350 French fragments in EN docs translated; docs/arkeon/ retired;
CLAUDE.md no longer rendered on the docs site; docs/fr/toc.yml created
(51 FR pages were orphans) and the ADR register joined the site nav.
New pages (EN + FR): reference/cli.md, reference/configuration.md,
architecture/mcp.md, architecture/studio.md. New guards in CI:
scripts/check-doc-claims.py (counts checked against the code), a
category-README completeness check, and an informational EN/FR drift
report. New community files: SUPPORT (EN/FR), NOTICE, CODEOWNERS,
dependabot, CodeQL, a documentation issue form.
Fixed — ExecutionPlanParser no longer throws on non-string JSON values
ExecutionPlanParser parses untrusted LLM planning output, yet "task": 42,
"instructions": 42, "agent": 42 or a number/null inside "dependencies"
escaped the JsonException net as an InvalidOperationException from
JsonElement.GetString() and took the whole planning pass down. Every
string-position read is now guarded by a ValueKind check: malformed entries
degrade the same way unknown ids always have (entry skipped, dependency
dropped, agent unassigned) instead of throwing. Surfaced by the SONAR-14
coverage pass on the parser.
Added — Studio i18n: the whole below-the-view layer follows the language switch (STUDIO-11 tranche 2)
Completes the sweep opened by PUB-19: the IStudioStrings registry grows from 4
to 89 keys covering every string Core and the WPF ViewModels fabricate — the
orkeon init preset catalogue (labels, guidance, plan errors, via
LlmPresets.CatalogFor), the settings resolution chain
(SettingsLocations.ResolutionChainFor), the mount-rights labels
(MountRightsTokens.ChoicesFor/GetLabel(rights, strings)), the directory-run
notice, the --mount override semantics, and all ViewModel statuses, summaries,
dialog titles and filters. Every long-lived ViewModel takes the port (optional,
English default — TUIs unchanged) and re-emits its bindings on CultureChanged;
transient rows (mounts, overrides, effective-mount table) are refreshed by their
owners. 89 keys mirrored EN/FR in the WPF resx; a new drift test pins
resx-EN ≡ EnglishStudioStrings so the two English surfaces cannot diverge.
Deliberately untranslated (CLI-contract policy, like VALIDATION OK/FAILED):
orkeon doctor check names/details, validator message bodies, target-detector
remediations, LLM probe results, and exit-code descriptions.
Added — Studio localization port: strings below the view layer follow the language switch (PUB-19 tranche 1)
Foundation for STUDIO-11 ("switching to French leaves some strings in English"):
Orkeon.Studio.Core gains a localization port (IStudioStrings, key registry,
English defaults) consumed by the shared formatters — ValidationMessageFormatter
and LaunchOutcomeFormatter first — through additive overloads (the TUIs keep the
English default, no regression). The WPF front bridges the port onto its
resx-backed I18n (I18nStudioStrings, hot language switch relayed via
CultureChanged), with the new keys mirrored EN/FR under the existing resx-parity
test. CLI verdict words (VALIDATION OK/FAILED) deliberately stay untranslated.
The ViewModel sweep continues on this pattern (tracked in STUDIO-11).
Added — Test pyramid rebalanced: offline E2E per orchestration mode, nightly integration, no swallowed SIGSEGV (PUB-17)
- E2E grows from 17 to 43 executed facts, all offline: a new suite drives the
full DI stack (Application + Infrastructure) through
ICrewOrchestrationService.KickoffAsyncfor each of the six orchestration modes (sequential, hierarchical, parallel, consensual, graph, autonomous) with a scripted LLM — per mode: the kickoff completes, produces exactly one output per declared task, and demonstrably drives the LLM (no silent no-op path). TwoCategory=Slowfacts spawn the realorkeonCLI from source and--validatea single-YAML example and a multi-file crew directory end-to-end. integration.yml: the Integration/Slow suites (Testcontainers databases) now run nightly (02:17 UTC, also dispatchable). A red run opens or comments a tracking issue — failures are visible, not buried in a log.- The blanket
continue-on-erroron Embeddings.Local is gone (ci.yml and publish.yml): the step now inspects the results — a genuine test failure fails the build; only the known ONNX teardown crash (exit 139 after a clean "Passed!" summary) is tolerated, explicitly and with a warning annotation.
Changed — Global zero-warning ratchet: full analyzer set on, CI builds -warnaserror (PUB-18)
The warning-debt story reaches its terminal state. The audit found the "frozen
~2 283 warnings" note in the build props was stale — the 2026-06-15 zero-warning
campaign had already resorbed the debt; a fresh full-analysis inventory
(AnalysisMode=All, AnalysisLevel=latest-all) surfaced only four stragglers
solution-wide, all fixed (unused TUI palette field, DefaultDllImportSearchPaths
on the libc kill P/Invoke, two per-call JsonSerializerOptions allocations).
The complete analyzer rule set is now enabled permanently in the root build
props, and CI compiles with -warnaserror — any new compiler, analyzer, or
NuGet-audit warning fails the build (audit advisories breaking CI is deliberate;
see the SSH.NET precedent). The .editorconfig ledger remains the record of the
deliberate per-scope arbitrations.
Changed — DI default stand-ins fully documented; the stub planner now warns (PUB-23)
The deliberately-minimal DI defaults follow the house rule — never a silent drop —
and the last gap is closed: AgentPlannerService (the fixed 4-step stub planner,
the most misleading stand-in since it emits a plausible "plan" every run) now
announces itself with a one-time Warning naming the replacement gesture, like
the Infrastructure stubs already did (previously Debug-only, invisible under
default logging). New page docs/getting-started/default-behaviors.md (EN + FR)
inventories every default — the six that warn, the six that are silent by design
and why — with the exact replacement snippet for each; linked from limitations and
the Autonomous orchestration guide. No functional behavior change.
Added — Google Gemini provider: 13th LLM provider (PUB-15)
GeminiLlmProvider joins the family through Google's OpenAI-compatible endpoint
(generativelanguage.googleapis.com/v1beta/openai, Bearer auth with the Gemini API
key). Capabilities verified against the compatibility documentation (2026-08-18):
effort-only thinking (reasoning_effort mapping to Gemini's thinking_level),
vision via image_url data URIs; response_format is undocumented on the compat
surface and therefore stays undeclared — a JSON-format request triggers the
structured capability warning instead of a silent drop. Factory auto-detection by
host (generativelanguage.googleapis.com) and model prefix (gemini-*); default
model gemini-3.7-flash; appsettings.gemini.local.json.example template added;
provider docs, comparison matrices and counts updated EN/FR. Vertex AI and AWS
Bedrock remain out of scope (OAuth/SigV4 SDK stacks conflict with the simple-HTTP
provider principle — recorded in the PUB-15 fiche).
Added — A2A task persistence lifts the 501; conformance matrix published (PUB-08)
GET /a2a/tasks/{id}is real now — opt-in: register a checkpointing state store (AddOrkeonCheckpointing/ SQLite / Postgres) plus the newAddOrkeonA2ATaskPersistence(), and the A2A server records every task lifecycle transition (IA2ATaskStoreport over the existingIStateStore, best-effort — a store outage never fails the task exchange). The endpoint answers200with the recorded state and404for unknown ids;DELETE /a2a/tasks/{id}now records the cancellation and answers404for unknown ids instead of fabricating an acknowledgement (cancellation stays advisory). Without the opt-in, the explicit501remains — never invented state.- A2A conformance matrix (
docs/reference/a2a-conformance.md, EN + FR): the honest inventory of the 0.x-era surface against the A2A v1.0 specification — operations, data model (5 vs 9 task states), bindings (own REST dialect; none of the three canonical bindings), security. Includes the PUB-08 certificate-revocation decision: short-lived certificates over CRL/OCSP for the private-CA mTLS model. - 6 new persistence tests (adapter round-trip + end-to-end HTTP 200/404/501/cancel).
Changed — MCP unpinned from 2024-11-05: dual-era client and server (PUB-07)
The MCP integration no longer hardcodes the first protocol revision. Both sides now
speak the modern stateless lineage (2026-07-28) and fall back to the legacy
initialize-handshake revisions, per the specification's backward-compatibility rules:
- Client (
McpClient.ConnectAsync): probes withserver/discover; a modern answer selects the newest mutually supported revision (renegotiating onUnsupportedProtocolVersionError, including mid-flight), anything else falls back to the legacyinitializehandshake — which now really negotiates (2025-11-25,2025-06-18,2024-11-05) instead of pinning2024-11-05, and finally sends the requirednotifications/initialized(it never did). Modern requests carry per-request_meta(protocol version, client info/capabilities);input_requiredinterim results (MRTR) are surfaced as explicit tool errors rather than partial data. - Server (
McpServer): dual-era on the same endpoint — implements the mandatoryserver/discover, validates the per-request declared version (-32022UnsupportedProtocolVersionErrorwith the supported list), answers legacyinitializewith real version negotiation, no longer replies to notifications, returnstools/listin deterministic order with the requiredresultType/ttlMs/cacheScopefields plus server identity in result_meta. - Wire: JSON-RPC ids are no longer int-only (string ids from external clients now
round-trip; stdio correlation is id-agnostic); the HTTP transport sends the
Streamable HTTP headers (
MCP-Protocol-Version,Mcp-Method,Mcp-Name) and unwraps SSE-framed response bodies. - 13 new dual-era tests (104 MCP tests total). Remaining gaps are documented in
docs/reference/limitations.md: nosubscriptions/listen, no MRTR, no MCP OAuth, JSON-response mode only on HTTP, and2025-03-26excluded (mandatory batching).
Added — Full API reference site and community templates (PUB-10, PUB-12)
- docfx now covers every published library (25 assemblies — 2 916 generated API
pages, nested-namespace navigation) instead of core + Tools.Abstractions only.
Metadata is generated from the compiled Release assemblies, so source-generated
members are included. The never-finished navigation is real now (
toc.yml,docs/toc.yml,api/index.md), and the newdocs.ymlworkflow builds the site strictly (--warningsAsErrors) on every PR touching sources or docs, and deploys it to GitHub Pages on eachv*tag. - The strict build flushed out 31 broken documentation links — including two references to the maintainers' private repository and four to a spec folder that no longer exists in the public tree — all fixed; ADR index pages (EN/FR) added.
- Community templates: structured issue forms (bug report with install-channel and reproduction fields, feature request), contact links routing questions to Discussions and security reports to SECURITY.md, and a pull-request template whose checklist mirrors what CI actually enforces (API freeze declaration, FR parity gate, CHANGELOG entry, executable bits, VFS-only I/O).
- 25 public entry-point types that had no XML
<summary>(RaggableTree contracts, scripting JS builders,BuilderValidationException…) are now documented.
Changed — Documentation debt cleared; FR/EN parity is now a CI gate (PUB-09)
- The examples catalog (
docs/reference/examples-catalog.md+ FR) is now editorial only: the generated, CI-checkedexamples/INDEX.mdis the authoritative inventory, so the page no longer maintains counts or paths by hand (the old page announced 104 examples with a table summing to 101 and folder casings that did not exist). It now also covers the RAG, RaggableTree, scripting, CLI-commands, local-embeddings and multi-file showcases. - The last four missing French mirrors are delivered (
run-your-first-example,example-data-policy,hosting, the example README template):docs/parity is green for the first time, andscripts/check-docs-parity.shnow runs inci.ymlon every push and PR — a missing mirror fails the build. CONTRIBUTING (EN/FR) states the CI-gate wording again.
Security — Testcontainers 4.13.0 → 4.14.0 (test infrastructure only)
Clears the last build warning, NU1903: Testcontainers 4.13.0 pulled SSH.NET 2025.1.0 transitively, which carries a known high-severity advisory (GHSA-q939-rpr3-3284); 4.14.0 depends on the fixed SSH.NET 2026.0.0. Test-only dependency — nothing shipped in the NuGet packages or installers was affected.
Added — The public API surface is frozen and enforced (PUB-05)
Ahead of the first public release, the API contract is now mechanical, not aspirational:
- PublicAPI baselines — every packable library carries
PublicAPI.Shipped.txt(29 235 declared public APIs across 28 projects, source-generated members included) and an emptyPublicAPI.Unshipped.txt, checked byMicrosoft.CodeAnalysis.PublicApiAnalyzers. An undeclared public API addition or removal is a build error (RS0016/RS0017promoted viaWarningsAsErrors). Executables (src/apps/) opt out — an app's surface is not a contract. [Experimental]on the unstable surfaces — 71 types now carryExperimentalAttributewith stable diagnostic IDs, documented indocs/reference/experimental-apis.md(EN + FR):ORKEXP001A2A (pre-v1.0.1 implementation),ORKEXP002Autonomous orchestration (budget, A2A channel, spawn),ORKEXP003corrective RAG (CRAG contracts),ORKEXP004MCP (pinned to2024-11-05until the protocol upgrade). Referencing them from outside the repository is a compile error to suppress explicitly;src/,tests/andexamples/suppress the four IDs centrally because the framework wires its own experimental surfaces.- Versioning policy — CONTRIBUTING (EN + FR) now states the contract: SemVer, a
breaking change is any edit to
PublicAPI.Shipped.txt,[Obsolete]ships at least one minor version before any removal, and no breaking change to a stable shipped API within the 1.x window. - The 47
InternalsVisibleTodeclarations were inventoried: 36 target test projects; the 11 production-to-production grants all belong to shared-kernel pairs already documented by ADR-002/003/006 (abstractions → implementation, Domain → Application/ Infrastructure, Cli.Abstractions → Cli) plus two grants to theorkeontool executable — kept, documented in the PUB-05 closure note.
Changed — Complete NuGet package metadata (PUB-04)
Every one of the 28 packable projects now ships presentation-grade metadata:
- Package icon per family —
assets/nuget/holds one 128 px icon persrc/zone (core, cli, scripting, analyzers, tools, rag, analysis, generators, hosting, plugins); a per-zoneDirectory.Build.propsdeclares the family and the central props pack it asicon.png, so every.nupkgcarries its zone's icon with a single<PackageIcon>. - A dedicated README per package — each project directory now has a short
README.md(role, install, doc links) packed viaPackageReadmeFile; the five projects that already had a rich developer README ship that one. The shared genericnuget/README.mdthat seven packages used to duplicate is removed. PackageReleaseNotes— centralized insrc/Directory.Build.props, pointing at this CHANGELOG.LICENSE.md— copyright aligned with the build props (2024-2026).
Changed — NuGet.org publication wired for real (PUB-03)
publish.yml now pushes the three core packages (Orkeon.Domain, Orkeon.Application,
Orkeon.Infrastructure — the v1 set of docs/reference/publication-matrix.md) to
NuGet.org on a v* tag. Authentication is Trusted Publishing (OIDC): a nuget.org
policy for Orkeon/orkeon + publish.yml lets NuGet/login exchange the job's OIDC token
for a short-lived key — no long-lived API secret exists anywhere. The steps are gated on the
NUGET_USER repository variable: until the owner finishes the nuget.org setup, they warn
and no-op instead of failing the release. The workflow
also refuses a tag that does not match the src/Directory.Build.props version — the
guard that makes the 0.9.1-beta silent-skip incident (rc tags re-packing an unchanged
version, --skip-duplicate skipping every push) structurally impossible. The README NuGet
badge, which pointed at a package that does not exist on nuget.org yet, is replaced by a
GitHub release badge until the first real push restores it.
Changed — Orkeon.Cli.Scripting renamed to Orkeon.Cli.Commands.Scripting (ADR-007, decision D3)
The library of TypeScript-scripted interactive CLI commands loses its near-anagram name
(Orkeon.Cli.Scripting vs Orkeon.Scripting.Cli): project, PackageId, assembly, root
namespace and test project are now Orkeon.Cli.Commands.Scripting(.Tests). The orkeon
dotnet tool (Orkeon.Scripting.Cli) keeps its name — its PackageId is the install command.
No published package carried the old name, so nothing breaks outside this repository;
in-repo consumers were updated in the same change. This supersedes ADR-004 and lifts the D3
gate in docs/reference/publication-matrix.md. Entries below in this Unreleased block use
the new name even where the work predates the rename.
Added — Orkeon Studio: a graphical way in, on Windows and Linux
Configuring Orkeon and launching a crew no longer requires a terminal. Orkeon Studio
ships as three applications over one shared core (Orkeon.Studio.Core, which holds the
appsettings model, the target detection and the orkeon run argument building — a feature
absent from the core exists in no UI):
orkeon-studio— a WPF desktop app for Windows, two tabs (settings editor, crew launcher);orkeon-studio-config— a full-screen Terminal.Gui editor for the settings file: provider presets, model and endpoint, and the VFS mount table, saved in the exact formFileSystemMount.Parsereads back;orkeon-studio-run— the crew launcher in the terminal: pick aconfig.yaml, a crew directory or a.ork.tsscript, set the options (--validateincluded), follow the output live, and cancel a run — the process is terminated and the exit code (130 on cancellation) is reported, with the UI still alive.
None of them is a second product: they edit the same appsettings.json orkeon init
writes, and they launch crews by executing the co-installed orkeon binary, so the CLI and
Studio are interchangeable on the same machine at any point.
Distribution follows the platform, not the wish list. The win-x64 zip and the MSI carry
orkeon-studio (the MSI adds an "Orkeon Studio" Start-menu shortcut, its only
MSI-specific authoring); the Debian package and the Linux archives carry
orkeon-studio-config and orkeon-studio-run (/usr/bin/orkeon-studio-{config,run} on the
.deb, launcher symlinks in <prefix>/bin from a tarball); the macOS onboarding channel
— the orkeon-cli-*-osx-* tarballs and Homebrew — stays CLI-only in V1 (the multi-app
orkeon-*-osx-* archives carry the two TUIs like every other RID, untested there).
Linux archives and the .deb hardlink-deduplicate their payload at staging
time (scripts/hardlink-dedup.sh — tar and dpkg both preserve hard links, and
gzip alone cannot deduplicate across files): the byte-identical .NET runtime
and shared Orkeon assemblies of the self-contained apps are stored once
instead of once per app, cutting the cli linux tarball from 176 MB to 104 MB
with strictly identical extracted content.
Every Studio app is published self-contained like the CLI itself, which keeps the .deb's
Depends free of any dotnet-runtime-* — the onboarding channel's invariant. The app table
in package-installers.sh / .ps1 gained a RID-filter column for this (WPF cannot target
non-Windows RIDs), and SHA256SUMS is unchanged in shape: same archive names, richer
contents.
The release smokes assert all of that on real runners rather than at packaging time:
orkeon-studio --smoke-exit opens the WPF window, lets it render and exits 0 on
windows-latest (both channels — zip and MSI — through one shared assertion file); the
.deb and linux tarball smokes require both TUI launchers and run --version on each with
no terminal at all (stdin from /dev/null, both streams redirected), which is the
contract that keeps them scriptable; and the macOS smoke asserts the absence of anything
named orkeon-studio*, so the day the RID filter regresses, CI fails instead of a Mac user.
Added — orkeon run <directory>: multi-file crews are a first-class target
A crew no longer has to be a single file. orkeon run (and every runner's -c/--config)
now accepts a directory: config.yaml for the crew settings, one agent per file under
agents/, one task per file under tasks/, each file-name stem being the entity id — the
layout YamlCrewDefinitionLoader.LoadFromDirectoryAsync already understood, which until now
no CLI could reach because the dispatch was by file extension only. The legacy flat triplet
(crew.yaml + agents.yaml + tasks.yaml) is accepted from a directory too, and every
option behaves identically on a directory and on a file (--settings, -V/--var,
--initial-context, --mount, --validate, --verbose, --llm-log). A .yaml path
passed directly follows exactly the path it always did.
The classification is explicit rather than convenient: a directory holding both a YAML
layout and a scripting entry point — any *.ork.ts or *.ork.js sitting directly in it,
whatever the file is called — is refused with both candidates named, and a directory with no
recognized layout is refused with the list of what was searched — no silent precedence, and
a lone script is never executed just because it was the only thing in the folder. The
directory is mounted read-only in the VFS as itself, not as its parent, so a crew directory
opens no wider a surface than a crew file. examples/crew-multifile/ is the runnable
reference (orkeon run examples/crew-multifile --validate).
Added — macOS channel: osx CLI tarballs, Gatekeeper handling, Homebrew formula
macOS joins Windows and Debian as a first-class install target. The release now carries
orkeon-cli-<version>-osx-arm64.tar.gz and -osx-x64.tar.gz — the orkeon CLI alone,
self-contained and tree-sitter-pruned like every other CLI package, cross-published from the
Linux runner (the apphosts ship in the SDK packs, the natives come from NuGet, and esbuild is
fetched per-RID as @esbuild/darwin-{arm64,x64}). No .NET install is required on the Mac.
install.sh gained a Darwin-only block that removes the two ways an unsigned binary fails on
macOS. It clears com.apple.quarantine from the installed tree — a browser download tags
every extracted file with it, which is what produces "cannot be opened because the developer
cannot be verified", and running the installer is the user's own act of trust. It then runs
codesign -v over the bundled Mach-O files and ad-hoc re-signs only those that fail,
because Apple Silicon refuses to load an unsigned Mach-O while a valid publisher signature
(onnxruntime's, for instance) must never be replaced by an ad-hoc one. Both halves degrade
quietly when xattr or codesign is unavailable, no individual failure aborts the install,
and the block is skipped outright off Darwin — Linux behaviour is unchanged. A new blocking
smoke-macos job (macos-latest, Apple silicon) installs the osx-arm64 tarball on a real
Mac and walks init → doctor → run → rag → uninstall on it, which is what actually proves the
signing story: a native library killed at load time fails there instead of in a user's
terminal.
A Homebrew formula ships in the repository at installers/homebrew/orkeon.rb: a binary
formula that fetches the tarball for the machine's architecture (on_arm / on_intel),
installs the payload under the Cellar's libexec, and writes a bin/orkeon wrapper pointing
ORKEON_ESBUILD_PATH at the bundled esbuild. Its test do runs orkeon doctor, not
orkeon --version (which exits 1). scripts/update-homebrew-formula.sh regenerates the
version and both url/sha256 pairs from a release's SHA256SUMS — idempotent, and it refuses
to write when the formula's structure no longer matches what it knows how to rewrite. The
Orkeon/homebrew-tap repository is not published yet, so brew install orkeon does not
resolve; until it is, the two sha256 values are explicit placeholders that fail verification
rather than fetch anything unverified.
Added — orkeon init and orkeon doctor
Two new verbs make the first ten minutes on a fresh machine self-service. orkeon init is
a wizard over five providers — ollama, docker-model-runner, openai, custom, none —
that writes an appsettings.json at the global per-user path (below), then probes the endpoint
to confirm it answers. Every prompt has a flag, so it scripts end to end:
--provider, --base-url, --model, --api-key-env (the recommended way to carry a key),
--api-key (inline, discouraged), --path, --force, --no-probe. With a non-interactive
stdin and no --provider, it refuses rather than hanging. orkeon doctor runs nine checks —
dotnet-runtime, appsettings, llm-config, llm-reachability, esbuild,
local-embeddings, onnx-reranker, tree-sitter, workspace-write — prints them as a
✅/⚠️/❌ table, exits 1 as soon as one fails (warnings stay green), and emits a
[{check, status, detail}] array under --json for CI.
Added — global per-user configuration path in the settings resolution
Settings resolution gains a fourth step: after the local appsettings.json and the walk up
the parent directories, and before the env-vars-only fallback, the CLI now reads
%APPDATA%\Orkeon\appsettings.json on Windows and ~/.config/Orkeon/appsettings.json on
Linux/macOS — the file orkeon init writes. An installed orkeon therefore works from any
working directory, and configuration never lives in the install directory, which every
(re)install deletes outright.
Changed — an unconfigured LLM warns instead of silently echoing
Building a runner host with no Llm section still falls back to the echo provider, but it now
says so once on stderr — "No Llm section configured — falling back to the echo provider
(<undefined-llm>). Run orkeon init to create a configuration, or set
ORKEON_Llm__BaseUrl / ORKEON_Llm__Model." The fallback itself is unchanged (it is what
makes the scripting demos runnable with no key and no server); what changes is that a crew
replaying its own prompts can no longer be mistaken for a crew talking to a model.
Changed — every publish prunes the unused tree-sitter grammars
Behaviour change. TreeSitter.DotNet ships one native library per supported grammar (31,
~69 MB on win-x64) while Orkeon only loads the seven declared in LanguageRegistry. A
PruneUnusedTreeSitterGrammars target in src/Directory.Build.targets — imported wholesale by
examples/Directory.Build.targets — now drops the rest from ResolvedFileToPublish, so every
dotnet publish under src/ and examples/ emits 7 grammars instead of 31, not just the
release archives. dotnet build is untouched. Opt out with
-p:OrkeonPruneTreeSitterGrammars=false (useful when diagnosing a grammar-loading problem).
Safe by construction — LanguageRegistry.Create throws for any language outside the registry —
and the whitelist ↔ registry agreement is pinned by TreeSitterGrammarPruningTests.
Added — Windows and Debian install channels; hardened install.ps1; runtime detection in install.sh
Three new release artifacts sit next to the existing multi-app archives, all self-contained:
orkeon-cli-<version>-win-x64.zip (the orkeon CLI alone, with install.ps1),
orkeon_<version>_amd64.deb (installable with sudo apt install ./orkeon_*.deb; depends on
system libraries only, through libicu/libssl alternations covering Debian 12/13 and Ubuntu
22.04→26.04, never on dotnet-runtime-*), and orkeon-<version>-win-x64.msi (WiX, per-user,
no administrator rights — one Windows channel at a time, the MSI refuses to install over a zip
install). release.yml is restructured into installers → {smoke-windows, smoke-deb, msi} → release, so nothing reaches the Release until it has been installed and exercised on a real
Windows runner and a stock Ubuntu image — the msi job carries its own
msiexec /i /qn → orkeon doctor --json → msiexec /x /qn smoke — and it now also runs on
workflow_dispatch (everything except the publication).
install.ps1 gained an "Apps & features" entry (with a working UninstallString), rescues an
appsettings.json left in a previous install directory into %APPDATA%\Orkeon before the
delete-and-replace, preserves the RegistryValueKind of the user PATH instead of flattening
REG_EXPAND_SZ to REG_SZ, and only checks for a .NET runtime when the payload actually needs
one (keyed on hostfxr.dll). install.sh gained the POSIX mirror of that check: it looks for
a framework-dependent app under libexec/ (no libhostfxr.so/.dylib), then for a
Microsoft.NETCore.App 10.x runtime on the PATH or under DOTNET_ROOT, and when it finds
none prints the exact commands per distribution — sudo apt install dotnet-runtime-10.0 on
Ubuntu 25.10+, the packages.microsoft.com repository registration on Debian and Ubuntu LTS,
dotnet-install.sh --runtime dotnet --channel 10.0 under $HOME without sudo — and repeats
the reminder at the end. It never installs a runtime, adds a repository or calls sudo on the
user's behalf, and the warning never blocks the install.
Fixed — package-installers.sh aborted at the checksum step with a single --rids
The final ls *.tar.gz *.zip *.deb | xargs sha256sum left one glob unmatched whenever the run
targeted a single RID; under set -o pipefail the failing ls took the whole pipeline down
and set -e aborted the script — after every archive had already been built. The ls is now
wrapped in { …; || true; }, and *.deb is part of the glob so SHA256SUMS stays complete
when package-deb.sh has dropped its package in the same output directory.
Added — shell_command: bidirectional VFS path rewriting
shell_command now speaks virtual paths in both directions, so a coding agent can run
dotnet build /workspace/App.sln instead of failing on a path that only exists in the
VFS. Inbound, every argument that names a mount-prefixed virtual path — including the
embedded --out=/workspace/dist form (split at the first =) — is resolved
virtual→physical through IFileSystemService.ResolveAndValidate before the process
starts, with a path-boundary check (/workspaces never matches mount /workspace); a
denied path fails the call with the redacted denial reason instead of reaching the
process verbatim. Outbound, stdout/stderr are rewritten physical→virtual before
truncation (success and timeout paths alike) using a per-call table built by resolving
each mount root — longest physical base first, backslashes normalized to / inside the
rewritten path token — so the model only ever sees virtual paths and stops leaking
physical host paths into its own follow-up file_read calls. Scope: AgentFacing
mounts, ordinal matching (re-cased Windows output is a documented limitation); with no
mounts configured both passes are exact no-ops.
Added — shell_command: configurable allowlist
Two new config keys shape the executable allowlist without code changes:
Orkeon:Tools:Shell:ExtraAllowedCommands (string array) is ADDITIVE on top of the
default allowlist — the recommended way to allow make/cargo/etc. for a trusted
coding-agent host; it composes with AllowInterpreters (new extraAllowedCommands
ctor parameter, unioned after the base list is built). Orkeon:Tools:Shell:AllowedCommands
(string array) is a full verbatim REPLACEMENT mapping to the existing allowedCommands
ctor parameter — per that contract it cancels AllowInterpreters and re-enables the git
read-only subcommand restriction. An absent or empty section binds to null, never to
an empty array (which would block every command), so defaults are unreachable by
accident.
Added — configurable LLM retry budget (default 10) + visible reconnection feedback
Llm:MaxRetries (the dormant LlmConfig.MaxRetries, never consumed until now) drives
BOTH HTTP paths: the buffered Polly policy (GetLlmApiPolicy, previously hardcoded at
5) and the streaming connect-phase loop (previously hardcoded at 3 attempts). Default
raised from 3/5 to 10 (LlmDefaults.DefaultMaxRetries) with every wait capped at
30 s (ResiliencePolicies.LlmRetryDelay — linear ×1/×2, then ×3 exponential, capped),
so the ladder degrades to a bounded cadence instead of 3⁸ seconds. Both config-binding
hosts read the key (ConfiguredLlmProviderBootstrapper for the ConsoleApp REPL,
RunnerHost for runner hosts), clamped at 0; the cap is applied before the TimeSpan
conversion (an arbitrarily large configured budget never overflows mid-retry) and a
server Retry-After is now capped at the same 30 s on the buffered path, matching the
streaming path.
What makes a 10-retry budget acceptable on an interactive turn is that it is now
VISIBLE: a new ILlmRetryObserver port (Application) receives every scheduled retry
wait and the final settle; HttpLlmProviderBase.RetryObserver fires it from both
paths (never on mid-stream failures, which are still not retried). The CLI implements
it with LlmRetryProgressObserver: the status line shows
✳ Reconnecting to api.moonshot.ai… retry 4/10 in 8s — <reason> through the existing
ProgressBroker, and the ambient CommandInstance gets the same line for
ps/inspect/the agents pane. Settling clears only the banner the observer raised
(label-guarded) — never a crew's own progress. No observer registered = behaviour
unchanged (retries only logged).
Fixed — transcript errors: one actionable line, never a stringified stack
A crew failure travels as
PromiseRejectedException(ObjectWrapper(AggregateException(HttpRequestException(SocketException))))
and its Message embeds the full stringified stack — which the REPL used to dump
verbatim into the transcript (the live /analyze incident: ~40 lines of .NET frames
for one DNS hiccup). New ConciseErrors helper (Orkeon.Cli.Commands.Scripting) unwraps the
wrapper layers (JS rejection → carried CLR exception, AggregateException flatten,
TargetInvocationException) and keeps the first line of the root cause —
✗ analyze failed: Resource temporarily unavailable (api.moonshot.ai:443). Applied at
every transcript-facing site (ScriptHostFacade crew failures, ScriptCommand
dispatch/completed rejections, CommandDispatchService instance failures); the full
exception still goes to the logs at each site.
Added — LLM streaming: connect-phase retry for transient failures
The buffered HTTP path runs under the Polly GetLlmApiPolicy, but
SendStreamingRequestAsync was a single bare SendAsync — one transient socket
failure killed the whole turn. It now retries the CONNECT/headers phase itself
(3 attempts, 0.5 s/1 s backoff, Retry-After honoured capped at 30 s) on transport
errors, client-side connect timeouts, and retriable statuses (408/429/5xx). Once
headers are handed to the caller, a mid-stream failure is never retried — replaying a
partially-consumed stream is the caller's decision. Non-transient statuses (401…)
return immediately, unretried.
Fixed — TUI: a line typed before the runner's first read was silently dropped
The split-pane input field is live from the first frame, but the scripted-commands
runner only starts reading after its startup script load (57 commands ≈ 30–60 s of
discovery → esbuild → evaluate). A line submitted in that window was echoed to the
transcript and then discarded — the live "que fait-on ?" incident: the free-text
request looked accepted and nothing ever happened. ReplPaneView now buffers
type-ahead submissions in a FIFO queue and delivers them to subsequent
ReadLineAsync calls — the same type-ahead semantics a plain terminal gives for
free. Pinned by three ReplPaneViewTests (buffered delivery, FIFO order, live read
still wins).
Fixed — scripted commands: async dispatch / async completed now awaited deterministically
ScriptCommand unwrapped an async dispatch's promise with the synchronous
UnwrapIfPromise (blocking the engine-lock thread until settlement) and the
completed drain did the same. Both now await UnwrapIfPromiseAsync — same pattern
as the sync-handler path — and surface a rejected dispatch/completed promise as the
same Error: … console line as a thrown one, instead of relying on Jint's blocking
unwrap semantics. Pinned by a dispatch-with-pending-promise integration test (the
/assistant shape since B-5: await session state, then post).
Added — end-to-end progress channel for long CLI operations
A ProgressBroker singleton (Orkeon.Cli.Commands.Scripting.Progress, registered by
AddScriptCommands) now carries a live {label, step/total | percent, message}
snapshot from whoever is doing long work to whoever renders it. Three publishers:
ctx.progress(...) handles from command scripts (which also stamp the ambient
CommandInstance.ReportProgress — the field ps/inspect exposed since design §6 but
nothing ever wrote); a new host tool progress_report so CREW scripts — which have
no ctx.progress — can report through the tools global (Tools.progressReport);
and an optional IProgress<IndexBuildProgress> hook on RaggableEnrichmentServices
notified by RaggableTreeBuilder per phase and per parsed file (null by default —
zero cost when unwired; the ConsoleApp routes it to the broker as "Indexing codebase").
The TUI status line renders the snapshot as
✳ Compacting conversation… ▰▰▰▱▱▱▱▱▱▱ 34% (12s) (indeterminate operations show
elapsed + message instead of a bar), including for background crews that run detached
from the REPL's own turn; the agents pane swaps a running row's intent for its live
progress. ProgressAmbient (AsyncLocal) links detached post/postWork flows to
their instance; completion clears the broker slot by ticket so one instance can never
erase a newer operation's bar.
Added — spinnerVerbs: configurable status-line verbs + spinner animation
The status line's verb rotation ("thinking verbs" in the tweakcc vocabulary) is now
configurable: TerminalGuiOptions.SpinnerVerbs seeds a boot-time list (bound from
Orkeon:Cli:Tui:SpinnerVerbs in the ConsoleApp), and the live
TuiIntegration.SpinnerVerbs delegate — wired by the ConsoleApp to the scripted /config
layers (config_map session state over /workspace/.orkeon/config.json) — wins over
it without a restart. The verb re-draws every fifteen seconds on long turns
(StatusLineFormatter.VerbFor), the leading glyph animates through spinner frames
(✢ ✳ ✶ ✻, ASCII | / - \) at four steps per second (SpinnerFrame), and the
status-line timer tightened from 1 s to 250 ms accordingly. Defaults unchanged: the
six Orkeon gerunds.
Fixed — agents pane: live work only, finished agents leave immediately
TuiFidelityWiring.BuildAgentRows collapsed every non-running instance to idle, so a
finished crew was indistinguishable from a stuck one (the exact confusion of the
2026-08-06 captures). Per the user ruling that followed — idle means waiting, not
finished — the pane now shows LIVE work only: a finished agent's row disappears at
once (no retention window, no terminal badges; ps/inspect stay the audit trail),
and idle never renders at all. ● main (filled bullet, no metrics) appears only
while at least one delegated agent runs — with nothing delegated the pane collapses,
matching the reference. Delegated rows render hollow (○) with the live
elapsed · ↓ tokens pair.
Added — ILlmUsageSink: per-call LLM usage events, per-agent token attribution
New Application port ILlmUsageSink (mirror of ILlmDeltaSink, same plumbing chain
JsEngineFactory → crewBuilder → JsCrew → JsLlmFacade): every ctx.llm.* path —
complete, chat, extract, decide, stream (both variants), and each act
iteration (buffered or streamed, counted exactly once) — reports a CostUsageEvent
carrying crew/agent/provider/model and the token split (a total-only response lands on
CompletionTokens so Prompt + Completion == TokensUsed — the pricing registry then
prices that total at the output rate, a deliberate upper bound: conservative for
budgets, an overestimate for cost reporting on split-less providers; a response with
no usage at all reports nothing — "no usage" ≠ "zero tokens"). extract reports
before its JSON parse, so a prose reply that throws still counts the paid tokens. Nothing fed ICostBudgetManager
before this: the REPL's session token readout summed an event stream no one produced.
AddScriptCommands registers InstanceAttributingUsageSink, which forwards to the
cost manager (fixing that readout and /cost) AND credits the CommandInstance
ambient at call time (ProgressAmbient, the progress channel's AsyncLocal) — so the
agents pane's ↓ NN.Nk tokens is now that agent's real usage (— only when truly
unattributable). CommandInstanceView gains tokens (visible to ps/inspect and
the F4 detail; typed in orkeon-cli.d.ts). A sink that throws degrades to unobserved
usage, never to a failed LLM call.
Added — agents pane: keyboard + mouse selection (F4)
The pane stays non-focusable at rest (its first live launch proved a focusable
read-only pane steals the prompt focus), but F4 now enters an explicit selection
mode: ↑/↓ move a chevron cursor, Enter prints the instance's detail into the
transcript (state, intent, elapsed, progress, result/error — via the new
TuiIntegration.DescribeAgent delegate over dispatch.get(ticket)), Esc hands
focus back to the prompt. A mouse click selects a row without stealing focus; a
double-click opens the same detail. The hint bar advertises f4 agents only while the
pane has rows. (F4, not Ctrl+A: the focused panes' select-all already owns Ctrl+A and
global bindings fire before view dispatch.)
Added — ActOptions.system: a real system prompt for scripted act() agents
ctx.llm.act(prompt, { system }) now seeds a role:"system" message as the first
message of the tool-calling conversation (re-sent on every loop iteration). Until now
act() always sent a single user message, so a scripted agent could not have a system
prompt at all — identity and tool policy travelled inside the user turn with user-level
authority (the same authority as tool results, which also come back as user turns), and
the providers' native system handling (Anthropic top-level system, cache_control;
PrependConfiguredSystemMessage on the OpenAI-compatible providers) never fired. The
conversation-level message wins over LlmConfig.SystemMessage on every provider; the
option omitted keeps the historical single-user-message shape byte for byte.
(JsLlmFacade.ResolveSystem, Typings/context.d.ts.)
Added — hybrid code search + edit↔search freshness in the RaggableTree (RAG×Tree)
codebase_search (and IRaggableStore.SemanticSearchAsync) fuses an embedding cosine
ranking with a code-aware BM25 (camelCase/snake_case sub-tokens + whole identifier)
via Reciprocal Rank Fusion — SemanticQuery.Mode (Hybrid default / Vector /
Lexical), SearchHit.MatchOrigin. Pure vector missed getUserById when the query
said "fetch user", and an exact identifier could rank below prose; each half now covers
the other's blind side. With no embedder wired, Hybrid degrades to Lexical instead
of the historical silent empty (explicit Vector keeps that contract). The BM25/RRF
implementations are Analysis-native twins of the RAG's (Bm25CodeIndex, RankFusion) —
the dependency must keep pointing Rag → Analysis, never back.
Freshness (an agent that EDITS files invalidates its own index): FileWriteTool gains
an optional IIndexInvalidation hook (dirty-marking, O(1), same precedent as the
citation validator); IndexFreshnessService reindexes the dirty set ∪ the git
working-tree changes (shell edits) BEFORE a read tool answers — grouped, single-flight,
2 s clean-probe debounce, failure degrades to the stale index and keeps the debt.
codebase_search reports refreshed_files; index_status reports dirty_count/
dirty_paths. InMemoryRaggableStore gains a ReaderWriterLockSlim: a search running
DURING an incremental reindex no longer risks InvalidOperationException on the mutated
dictionaries. The freshness pass is the first real producer on IRaggableTreeEventBus;
IGitDiffProvider (never registered before — incremental_reindex's commit-range path
could not resolve it) and the bus are now registered by AddRaggableTree, and
IGitDiffProvider gains GetWorkingTreeChangesAsync.
Orkeon.ConsoleApp wires AddOrkeonRag + AddOrkeonRagTools: rag_search/rag_ingest/
rag_eval are available to the scripted REPL, and rag_search's raggable-tree
collection inherits the hybrid + freshness path. Live-validated end to end (an internal probe crew,
8/8): exact identifier ranks hybrid, write→search round-trip reports
refreshed_files: 1, RAG routing serves the code index.
Changed — ctx.llm.stream now asks for usage and carries the reasoning channel (SCR-24)
stream went through GenerateStreamingAsync; it now goes through ChatStreamingAsync. Both read the same SSE stream, and the difference is what they ask for: the chat path sends stream_options: { include_usage: true }, without which most providers emit no usage chunk at all — so a streamed call had no token accounting. Measured on a live run whose two streamed requests were {"model":…,"stream":true} with no stream_options; they carried usage only because Moonshot volunteers it, and the same round on OpenAI would have reported nothing. The calls that stream are the long, expensive ones.
The plain path also dropped delta.reasoning_content, so a thinking model's stream is silent for as long as it thinks — round-41's deliverable 13 spent 22 673 of its 32 627 completion tokens reasoning, most of a nine-minute call in which "no chunk yet" and "the stream died" were the same observation.
stream(prompt)keeps yielding strings — no contract change. What the chunks cannot carry is exposed on the returned object:usage({ promptTokens, completionTokens, tokensUsed, cacheHitTokens, model },nullwhile the stream runs andnullfor good when the provider reported nothing) andreasoningChunks. Read them after the loop.- Deliberately NOT callbacks. A callback has to be invoked from the stream's own thread, and Jint's
Engineis single-threaded: the first cut of this did exactly that, and a measured run — seven area writers streaming concurrently — died of aNullReferenceExceptioninsideScriptFunction.Call, with all seven writers falling back to a placeholder and the script stopping silently after assembling its document. CLR state read through interop runs on the engine's own thread. - Reasoning progress is logged by the facade through the host logger every 200 deltas: a script cannot log it for itself, because while the model reasons its loop body never runs.
usageis populated on the non-streaming fallback too, so "this provider does not stream" and "this provider reported no usage" stay distinguishable.
Added — rag.retrieve / IRagRetrievalCapable: retrieval without the generation nobody asked for (SCR-24)
A caller that wants the retrieved passages rather than prose was still charged for a full grounded generation, because IRagPipeline exposed only QueryAsync. Measured on the 2026-08-04 gap round: seven rag.query calls whose generated answers were discarded by design cost 14 748 completion tokens — 68 % of them reasoning tokens — and 394 s of wall time, on top of retrieval that had already produced every citation the caller used. The generation stage is the expensive half of a RAG call and it is optional far more often than the API shape suggested.
IRagRetrievalCapable(Orkeon.Rag.Abstractions) — opt-in capability, same shape asIHybridSearchCapable:RetrieveAsyncrunstransform → retrieve → fuse → rerank → assembleand stops. Declared as a separate interface rather than added toIRagPipelinebecause not every executor can honour it — the corrective graph interleaves evaluation with generation, so "retrieval only" is not a prefix of its run — and a caller must be able to ask instead of discovering the answer through an exception.StagedRagPipelineimplements it;QueryAsyncandRetrieveAsyncnow share oneRetrieveCoreAsync, so the two cannot drift. The returnedRagAnswerkeeps the same shape (emptyText, populatedCitations) and the trace carries ageneratestep saying the stage was skipped on purpose — an absent step would read as a trace from an older pipeline.rag.retrieve(question, options)in the scripting DSL, same signature asrag.query. On a pipeline that is not retrieval-capable it throws rather than falling back toQueryAsync: a silent fallback would charge exactly what the caller asked to avoid, with no way to tell.
Fixed — the system role was flattened into the user message on every OpenAI-compatible provider (SCR-24)
HttpLlmProviderBase.ChatAsync flattens messages into one prompt shaped "{role}: {content}" per line, and OpenAICompatibleProviderBase took the structured chat path only when tools, tool-call metadata or a vision payload were present. A plain system + user conversation — the shape of the entire RAG generation stage, and of every LLM judge, retrieval evaluator and groundedness checker in this repository, none of which declares a tool — therefore reached the provider as a single user message whose text began with system: . A multi-turn history was concatenated the same way, so an assistant turn arrived as something the user claimed the assistant had said. Evidence: the run's exchange log, all seven RAG generations sent as messages: [{ role: "user", content: "system: You are a retrieval-augmented assistant…" }].
- Every
ChatAsynccall with at least one message now takes the structured path. A lone user message was affected too (it went out as"user: Hello."), so no case is left on the flattening path; an empty or null array still delegates to the base, which turns it into an empty single prompt. grammar(GBNF) was emitted only by the single-prompt builder and is now written by both, so an option cannot appear or vanish with the number of messages sent.- Around 400 mocked provider tests missed this: they assert on the response, and a mocked handler answers whatever it is sent. The new
OpenAICompatibleProviderBaseRoleFidelityTestsread the outgoing payload on the cases with no tool and no image, which was the remaining blind spot.
Added — Declared provider capabilities, structured outputs and thinking on all 12 providers (LLM-02, LLM-03, LLM-04)
The audit's central finding was not that the wiring was missing but that its absence was invisible: LlmResponseFormat and LlmThinkingConfig cascaded correctly from crew → agent → task → script → call-site, yet only DeepSeek wrote response_format and only DeepSeek and Z.AI wrote thinking. On the ten other providers a YAML declaration was silently dropped. Closes G-15, G-16 and G-19.
LlmProviderCapabilities(Domain) — each provider declares what its API really supports:ResponseFormat(None|JsonObject|JsonSchema),Thinking(None|EffortOnly|Toggle|Budget),Vision,ExplicitPromptCaching,RequiresJsonKeywordInPrompt,ReplaysReasoningContent. Exposed onILlmProvideras a default-implemented member (likeBaseConfig), so third-party providers and test doubles keep compiling, and a provider that declares nothing gets nothing written on its behalf.- The end of silent drops — an option the caller declared that the provider cannot honour now produces an actionable warning naming the option, the provider and the remedy. This is the actual fix for G-15/G-16; the wiring below is the easy half.
- The OpenAI dialect is written once.
OpenAICompatibleProviderBase.ApplyProviderSpecificOptionswent from a no-op hook to a capability-driven implementation. DeepSeek and Z.AI held two near-identical copies of the thinking translation and two of thereasoning_contentextraction; both were refactored onto the base and shrank to what is genuinely theirs (DeepSeek's cache counters, Z.AI'sreasoning_tokens). Their 33 + 15 existing tests pass unmodified. - Structured outputs on all 12 providers (G-15) —
LlmResponseFormatgains an optionalSchema(LlmJsonSchema { Name, Schema, Strict }) and aJsonSchema(...)factory;Typeis untouched, so existing configurations behave identically. Dialects:response_format(OpenAI-compatible family, withjson_schemawhere the vendor validates it),output_config.format(Anthropic),format(Ollama, which also accepts a full schema). A schema handed to a provider that only guarantees well-formed JSON is downgraded tojson_objectwith a warning, never in silence. - ⚠️ Anthropic has no schema-less JSON mode.
output_config.formattakes exactlytype(alwaysjson_schema) andschema— there is no equivalent ofjson_object, andname/strictdo not belong there (strictexists, but on individual tools). Ajson_objectrequest on Anthropic is therefore reported, not sent in a shape the API would reject. Supply a schema, or state the shape in the prompt. - ⚠️ The YAML allow-list is lifted.
YamlCrewMapperaccepted onlytextandjson_objectand downgraded everything else tonull— which would have discardedjson_schemabefore it reached any provider. Unknown values now travel to the provider with a warning: a new vendor value works without a framework release, and a typo surfaces as a provider error rather than as nothing at all. Declare a schema with aresponse_schema:block (name,schema,strict) next toresponse_format: json_schema; aresponse_schema:on its own impliesjson_schema. The scripting DSL gainswithResponseSchema(name, schema, strict?)on both the agent and task builders. - Thinking on all 12 providers (G-16, G-19) — four dialects:
reasoning_effort(+ thethinkingblock where the API has an explicit toggle) on the OpenAI-compatible family,thinking: {type: "adaptive"}+output_config.efforton Anthropic,think(boolean or effort level) on Ollama,enable_thinking+thinking_budgeton Qwen's DashScope dialect.LlmThinkingConfiggainsBudgetTokens, mapped only to Qwen — thebudget_tokensshape found in many older sources is rejected with an HTTP 400 by the current Claude generation, so it is reported rather than sent. reasoning_contentextraction is generic; its replay stays DeepSeek-only, driven byReplaysReasoningContent— it is an API constraint of theirs (HTTP 400 without it), not a property of reasoning models, and Z.AI documents the opposite.- Ollama's
formatandthinkare wired on the current/api/generatepath: contrary to what the audit reported, both are accepted there, and only native tool calling actually requires the/api/chatmigration.
Added — orkeon llm probe, the provider campaign harness (LLM-08/C1)
Roughly 400 unit tests cover the LLM providers and every one of them speaks to a mocked HTTP handler. A mock proves the framework sends what we believe it sends; it cannot prove the vendor accepts it. Before this, only DeepSeek had archived real-execution traces.
orkeon llm probe --provider <name> [--model …] [--base-url …] [--modes M1,M8] [--archive <dir>]runs the matrix's protocol modes against a live provider and prints a report ready to paste into the matrix journal, evidence level included. Currently exercises M1 (single prompt), M2 (multi-turn + system), M3 (text streaming), M4 (chat streaming), M7 (thinking), M8 (response format), M12 (typed error on an invalid model) and M13 (cancellation). M5/M6 (tool calling), M9 (vision), M10 (cache) and M11 (long context) need per-provider assets or a deliberately expensive call and are not covered yet — the harness names what it does not run rather than implying full coverage.- The API key is never a command-line argument: it is read from an environment variable (
--api-key-env, defaultORKEON_LLM_API_KEY) and never appears in the report or the archive. - A mode that a provider's declared capabilities make inapplicable is reported as such, not as a failure; a mode that throws is recorded as a finding rather than aborting the campaign, since the framework's contract is a typed error response, never a throw. A campaign with any failed mode exits non-zero.
- The campaigns themselves are not run here. They consume real credits on real accounts, so the decision to spend belongs to whoever owns them; the remaining LLM-08 work (running the campaigns, replaying experiment 08, filling the matrix) is unblocked but outstanding.
Added — Azure v1 GA API and native Ollama tool calling (LLM-07)
The two gaps that needed a pipeline migration rather than a payload field. Closes G-06, G-10 and G-25.
- Azure v1 GA API (G-06, G-25) —
BuildEndpointwas hardcoded to the dated, deployment-based shape (/openai/deployments/{deployment}/chat/completions?api-version=2024-02-01). Azure has served a v1 GA surface since August 2025 —{endpoint}/openai/v1/chat/completions, noapi-version— which is the only path to the Responses API and to the non-OpenAI models Azure resells (DeepSeek, Grok); all of it was unreachable. Select it withapi_version: v1(typed property or custom parameter). The dated shape stays the default on purpose: switching it would silently change the URL of every existing deployment-based configuration. - ⚠️ Ollama tool calling is now native (G-10) — the provider targeted
/api/generate, which has no tool support, so tool calling went through the text-fallback protocol. It now reaches/api/chatwhenever the conversation declares tools, replays tool calls, or carries an image, andLlmProviderFactorywires it with the native OpenAI strategy. The response is reshaped once — Ollama returnsmessage.tool_callswithargumentsas a JSON object and nochoicesarray, which is precisely why the framework's single parser could not read it — into the OpenAI body (argumentsas a JSON string, positional call ids since Ollama issues none). Everything else keeps/api/generate, so NDJSON streaming and GBNFgrammarare untouched and the text fallback remains in charge for models without tool support. - Ollama vision — images travel in a bare base64
imagesarray rather than as OpenAI content parts. Remote image URLs cannot be forwarded (the server never fetches them), so an image referenced only by URL is skipped rather than silently dropped. docs/reference/limitations.mdno longer describes a limit that has been lifted; its Ollama entry now states what actually remains (no/api/chatstreaming, bytes-only images, text fallback for tool-less models).
Added — Anthropic parity, vision exposure and HuggingFace routing (LLM-05, LLM-06)
- Native SSE chat streaming on Anthropic (G-20) —
ChatStreamingAsyncused to fall back to the base class's buffered emulation, which waits for the whole answer before emitting anything: no token ever arrived early on Claude. It now parses the Messages API event stream directly, emittingContentDelta,ReasoningDelta(extended thinking) and a terminalCompletedwhose response is indistinguishable from the buffered one. - Anthropic cache metrics (G-21) —
cache_creation_input_tokensandcache_read_input_tokensfeed the typedCacheHitTokens/CacheMissTokens, soCacheHitRatiois finally computable on Claude. The three input counters do not overlap, soPromptTokensis their sum; absent counters staynull(unmeasured, not zero). - Explicit prompt caching (G-17) — new
LlmCacheConfigvalue object, cascaded likeLlmThinkingConfig(crew → agent → task → call-site,cache:block in YAML). Anthropic's cache is explicit: without acache_controlbreakpoint nothing is cached, so a long system prompt is re-billed in full on every turn — Orkeon read the metrics but never placed a breakpoint. Off by default, since a breakpoint changes what the vendor stores and how the call is billed. Marking the system prompt promotes it to the content-block form the API requires; the tools breakpoint lands on the last tool, which covers the whole catalogue. - Vision exposed on the 8 remaining capable providers (G-18) — Azure, Groq, Together, Mistral, Qwen, Kimi, Z.AI and HuggingFace declare the capability and inherit the
image_urlcomposition from the base; no per-provider override. Text-only calls emit exactly the payload they did before (asserted). Ollama stays out on purpose — it takes a base64imagesarray rather than OpenAI content parts, which belongs with the rest of its request-shape work. DeepSeek has no vision model and degrades an image message to its text fallback. - HuggingFace provider-selection suffixes (G-23) —
:fastest/:cheapest/:preferredand partner pinning (:groq) are the only cost and latency lever on Inference Providers. The suffix already travelled to the wire; what was missing was any way to know it was wrong. AddedWithRoutingPolicy/WithPartnerhelpers and validation that reports an unrecognised suffix while still forwarding it — the partner list moves faster than this framework releases. - Guard on DeepSeek's Anthropic-dialect endpoint (G-22) —
api.deepseek.com/anthropicspeaks the Messages API, but host inference matcheddeepseek.comand routed it to the OpenAI-compatible provider, producing malformed requests whose error surfaced far from its cause. It now fails immediately with a message naming the cause and the fix. - G-24 (Groq server-side tools) is ruled out with a motive, not left pending.
browser_searchandcode_interpreterexecute on Groq's side, so they pass through neitherIBaseTool, nor the agent loop, nor the framework's validation, rate limiting and telemetry. Exposing them is a tooling-architecture decision — how they coexist with Orkeon's tool registry and what gets traced — not a provider-support gap. The same reasoning covers the other vendors' server-side tools.
Changed — LLM provider defaults, host routing and the Azure config guard (LLM-01)
First sheet of the LLM provider remediation plan, closing gaps G-01→G-05, G-07→G-09, G-11, G-12, G-14 and defect D-01 of the 2026-07-27 audit. Four providers out of twelve failed with their out-of-the-box configuration; four more targeted a superseded generation.
- ⚠️ Default models changed — this changes the behaviour of every configuration that does not specify a model. Each identifier was confirmed on the vendor's official documentation on 2026-07-27: OpenAI
gpt-4→gpt-5.6-sol(gpt-4reaches end of life 2026-10-23 and caps context at 8 192 tokens), Anthropicclaude-3-5-sonnet-20241022→claude-sonnet-5(retired 2025-10-28), DeepSeekdeepseek-chat→deepseek-v4-flash(retired 2026-07-24), Kimimoonshot-v1-8k→kimi-k2.6(themoonshot-v1-*series sunsets 2026-08-31), Qwenqwen-turbo→qwen3.7-plus(absent from the catalogue), Mistralmistral-large-latest→mistral-medium-3-5-26-04.LlmDefaults.DefaultModelName— the fallback ofLlmConfig.Modelitself — moves with the OpenAI default. Pin a model explicitly to keep the previous behaviour. - ⚠️ Default endpoints changed — HuggingFace
api-inference.huggingface.co→router.huggingface.co(the old host is gone, so the provider could not work at all), Kimiapi.moonshot.cn→api.moonshot.ai(the mainland host was the default for every account, including international ones; setBaseUrlexplicitly for a mainland account). - Every default now lives in
ProviderDefaults— Groq and HuggingFace held theirs inline — and a pinning test asserts the model and endpoint of all twelve providers, read from live instances rather than from constants. Drift becomes a failing test, not a silent change. - International hosts are routed to their dedicated provider instead of falling back to the generic OpenAI one:
api.moonshot.ai(G-11),dashscope-intl.aliyuncs.comand the per-workspace*.maas.aliyuncs.comhosts (G-12).router.huggingface.cowas already matched by the existinghuggingface.corule — the audit's G-13 was a false positive, and is now pinned by a test. - Mistral routing trap fixed (G-14) —
InferFromModelrouted everymistral*model to Ollama, so a cloud identifier such asmistral-medium-3-5-26-04was sent tolocalhost:11434. Only the baremistraland its Ollama tags (mistral:7b) stay local; versioned identifiers, includingministral-*, now reach the Mistral cloud. SettingBaseUrlstill wins over model-name inference. - Azure configuration guard aligned across all four entry points (D-01) —
ChatStreamingAsyncwas not overridden, so with a missingBaseUrlit reachedBuildEndpointand threw aNullReferenceExceptionwhere the other paths returned a typed error. It now emits the standardCompletedevent carrying that same error.GenerateStreamingAsyncstill ends in an empty stream —IAsyncEnumerable<string>has no error channel — but logs it instead of failing silently. - Cost tracking follows —
gpt-5.6-sol/-terra/-lunaandclaude-opus-5/claude-sonnet-5registered inModelPricingRegistry;gpt-4keeps its own entry so configurations that pin it still produce a real cost. LlmConfig.Gpt4()→LlmConfig.WithDefaultModel()(andGpt4WithSecret→WithDefaultModelSecret). These factories have always returned the platform default model, not literallygpt-4; with the default moved, the old names became actively misleading. The former names remain as[Obsolete]forwarders with identical behaviour — pass"gpt-4"toLlmConfig.Createif you really want that model.
Fixed — post-RAG coherence audit (2026-07-26)
LlmProviderToChatClientAdapterhonors the standardChatOptions.ResponseFormat— the adapter only read theLlmChatOptionsKeys.ResponseFormatAdditionalProperties key stashed by the orchestrator, so callers built on plain Microsoft.Extensions.AI options (RAG retrieval evaluator, groundedness checker, query complexity classifier settingChatResponseFormat.Json) never reached providers wiringresponse_format(e.g. DeepSeekjson_object). The adapter now falls back toChatOptions.ResponseFormatwhen the key is absent (the explicit key keeps priority) — the JSON constraint was already enforced by strict prompts + tolerant parsing, this makes the API-level guarantee real on the providers that support it.- The corrective mechanism test now locks the rank-1 claim —
CorrectiveRagMechanismSlowTestsasserted only thatnotes-power.mdwas cited; it now also asserts it is the first citation, matching the wording in the RAG-06 task sheet and the eval README. - Documentation drift cleanup: version references aligned on
src/Directory.Build.props(0.9.2-beta) acrossREADME(.fr).md,CLAUDE.md,limitations.md,publication-matrix.md;publication-matrix.mdworkflow narrative matched to reality (all NuGet pack/push lives inpublish.yml→ GitHub Packages; nothing on NuGet.org);CONTRIBUTING(.fr).mdno longer claims a CI parity gate that was never wired; ADR-006 amended with the RAG-06 decisions (CRAG onStateGraph, web-fallback policy/transport split,correctivepreset, no separaterag-adr.md);docs/INDEX.mdlinksrag-pipeline.md;limitations.md+opt-in-subsystems.mddocument the ONNX requirement (balanced/quality/adaptive) and the double-opt-in web fallback; French mirrordocs/fr/architecture/rag-pipeline.mdadded; eval README header corrected to 9 cases / 12 documents;Orkeon.Tools.RagNuGet description lists its three tools; stale "lands with RAG-0x" comments rewritten in delivered code.
Added — Corrective RAG & vitrine (RAG-06): CRAG graph on StateGraph, corrective profile, opt-in web fallback, examples
The showcase piece of the RAG plan (guide §8): Corrective RAG built on Orkeon's own Graph orchestration mode — the corrective engine is a Domain StateGraph, RAG demonstrates the Graph mode and vice versa — plus the vitrine layer (docs, three runnable examples, final all-profile evaluation).
- CRAG graph pipeline (C1) —
CorrectiveRagPipeline(Orkeon.Rag.Corrective), anIRagPipelinewhose execution is aStateGraph<RagGraphState>(immutable record state) with conditional edges and controlled cycles:retrieve→evaluate(IRetrievalEvaluator→RetrievalVerdictCorrect | Incorrect | Ambiguous) → per verdictgenerate/refine(decompose-then-recompose, never empties the working set) /rewrite_query(vocabulary-gap rewrite, loops back toretrieve) →generate(original question, same[n]rank-based markers, token budget andedgeslayout as the staged pipeline) →check_groundedness(IGroundednessChecker; ungrounded → re-loop). Every node run is traced ascorrective:<node>with the iteration ordinal; verdicts, rewritten probes and loop count land inRagTrace.Verdicts/QueryVariants/Iterations. Evaluator and checker are LLM-backed when anIChatClientis registered (LlmRetrievalEvaluator/LlmGroundednessChecker, constrained viaLlmResponseFormat—json_objectwhere wired, e.g. DeepSeek — tolerant JSON parsing elsewhere), deterministic heuristics with a warning otherwise.AddOrkeonCorrectiveRag(configuration)(idempotentTryAdd, called byAddOrkeonRag). - Double loop bound (C1) —
Orkeon:Rag:Corrective:MaxIterations(default 3) bounds both the rewrite cycle and the groundedness re-loop, and the graph engine's ownCircuitBreakerPolicyis explicitly derived from that budget as a second, independent layer. Exhaustion → best-effort generation with the best available chunks, traced; a tripped breaker is caught, traced (corrective:circuit_breaker) and degraded — the pipeline never throws for a loop condition and can never loop forever (circuit-breaker test included). - Opt-in web fallback + anti-injection (C1 security, 6C) — after rewriting is exhausted the graph may fire
web_fallback, gated by two separate off-by-default switches:Orkeon:Rag:Corrective:WebFallback(pipeline policy, Abstractions) andOrkeon:Rag:WebFallback(transport —WebSearchDocumentRetriever, SearxNG-compatible JSON search,ApiKeyEnvVaronly,AddOrkeonRagWebFallback). Every downloaded page passesPromptInjectionDocumentValidator(deterministic heuristics — model-addressed directives EN+FR, chat-template control tokens, hidden HTML, exfiltration vectors; verdictClean | Suspicious | Rejected):Rejectednever leaves the retriever,Suspiciousis flagged or discarded perSuspiciousAction, content is never rewritten. Threat model + honest limits (pattern-based, evadable) indocs/architecture/security.md. Web chunks carryScoreOrigin = "web". correctiveprofile + Adaptive lift (C2, 6D) —RagProfile.Corrective/correctiveinRagProfilePresetsand the resolver (sameIRagPipelinefaçade; the profile selects the executor). Preset: hybrid BM25 + RRF (rewritten probes need the lexical leg), no linear rerank stage (the graph corrects by looping — no ONNX package needed),Groundedness.Enabled = falseon purpose (the graph runs its nativecheck_groundednessnode whenever a checker is registered). Theadaptiveprofile'sIterativeroute now delegates to the memoizedcorrectivepipeline — the RAG-05 documented fallback toqualityis lifted (routestep tracesdelegate=corrective).- Vitrine (C3, 6B) —
docs/architecture/rag-pipeline.mdcompleted (CRAG topology, verdicts, double bound, web fallback, ADR pointers); three new runnable offline examplesexamples/rag/hybrid-retrieval(BM25+RRF vs vector-only),examples/rag/custom-reranker(hostIRerankerviaIRerankerRegistrar),examples/rag/crew-yaml(crewrag:/knowledge:blocks) + scripting variantexamples/scripting/08-rag.ork.ts; all fourexamples/rag/*projects inOrkeon.Examples.sln;examples/INDEX.mdregenerated. - Final evaluation, published honestly (C3) —
orkeon rag eval --compare fast,balanced,quality,corrective,adaptive(2026-07-26, offline): fast/balanced/quality/adaptive at 0.89 recall@5 / 0.89 MRR;correctiveat 0.78 / 0.64 — worse thanqualityoffline, and documented as such: the extractive stub degrades the graph's LLM nodes (pseudo-random verdicts from the tolerant parser, degenerate rewrite probe shared by all cases), so the offline row measures the loop's guard rails, not rewrite quality — full per-case analysis inexamples/rag/eval/README.md. The causal end-to-end proof of the mechanism (verdictIncorrect→ rewrite →notes-power.mdcited on the seeded q-007 case thatqualitymisses, same store and embeddings) isCorrectiveRagMechanismSlowTests(scripted LLM for the two roles CI cannot provide, labelled). CI gate unchanged (balanced,correctifcases excluded, gated aggregates 1.00/1.00).
Added — RAG query translation & adaptive routing (RAG-05): transformers, MMR, classifier, Adaptive profile
Stage 1 of the pipeline (query transformation, guide §6) plus Adaptive-RAG routing (guide §8.4), measured with the RAG-04 harness.
- Query transformers (C1) —
IQueryTransformer { Name, Kind, TransformAsync }with retrieval semantics perQueryTransformKind:MultiQueryTransformer(multi-query, Union — one LLM call produces N phrasings, retrieval runs per query, rankings merged by chunk-id union keeping the original store scores, max on duplicates),RagFusionTransformer(rag-fusion, Fusion — same variants, per-query rankings fused by Reciprocal Rank Fusion, same k as the hybrid stage),HydeTransformer(hyde, Replacement — a hypothetical document is embedded as the retrieval probe INSTEAD of the question; generation and citations always use the ORIGINAL user question),IdentityQueryTransformer(none). Factory pre-populated viaAddOrkeonQueryTransforms()(chat client resolved lazily; unknown names fail loudly); optionsOrkeon:Rag:QueryTransform(Modedefaultnone,VariantCountdefault 3). Unusable/failing LLM responses degrade to[original]with a warning — never an exception. - Pipeline integration (5D) — the
StagedRagPipelinetransform stage resolves the configured transformer and wires retrieve/fuse perKind(union / rrf / replacement probe). The transform step tracestransformer,kind,variants(count) and the truncatedvariant_ntexts;RagTrace.QueryVariantscarries the full produced texts (original excluded); the fuse step traces itsmethod(union/rrf/dedup). - MMR diversification (C2, opt-in) —
MaximalMarginalRelevance.Selectapplied after fusion/dedup and before rerank whenOrkeon:Rag:Retrieval:Mmr:Enabledis set (Lambdadefault 0.7): re-orders the fused candidates byλ·relevance − (1−λ)·redundancy. Candidate embeddings are not recomputed — the documented lexical (Jaccard) fallback with min-max-normalised scores is the pipeline path; the embedding overload exists for callers that already hold them. Traced on the fuse step (mmr,mmr_lambda,mmr_in/mmr_out). - Query-complexity classifier (C3) —
IQueryComplexityClassifier.ClassifyAsync(query) → QueryRoute { NoRetrieval, SingleShot, Iterative }:HeuristicQueryComplexityClassifier(default — deterministic rules, zero LLM) andLlmQueryComplexityClassifier(constrained JSON, tolerant parsing, SingleShot fallback with warning).AddOrkeonQueryRouting(configuration), optionsOrkeon:Rag:QueryRouting:Classifier(heuristic|llm;llmwithout a chat client falls back to heuristic with a warning). Adaptiveprofile (C3/5D) —RagProfile.Adaptive/adaptiveinRagProfilePresetsand the resolver: theAdaptiveRagPipelineclassifies first, thenNoRetrieval→ direct LLM answer (no retrieval, empty citations),SingleShot→ delegates to the memoized balanced pipeline,Iterative→ documented fallback toqualityuntil the corrective/iterative engine ships with RAG-06. The decision is always traced:RagTrace.Route+ a firstroutestep (route,classifier,delegate, fallback detail).Orkeon:Rag:Profile=adaptiveroutes the default pipeline through the resolver.- Measured comparison (golden dataset, offline, heuristic judge, 2026-07-26): fast / balanced / quality / adaptive all at 0.89 recall@5 / 0.89 MRR (4 / 217 / 166 / 141 ms/case). Adaptive equals balanced on this dataset by construction: the heuristic classifier routes all 9 golden questions to SingleShot → balanced (each has exactly one interrogative word, a single
?, and fewer than 25 words); the ms/case delta is a warm-ONNX-session artifact of run order, not a quality gain. Offline honesty:--offlineswaps in a deterministic extractive chat client, so the LLM-backed transformers (multi-query / rag-fusion / hyde) and thellmclassifier have no real LLM to call — the measured path isMode=none+ heuristic routing; the transformer/MMR levers are wired and unit-tested, their quality delta will be measured with a realIChatClientand/or the RAG-06 corpus growth.
Added — RAG quality phase (RAG-04): evaluation harness, hybrid retrieval, reranking, profiles
Measured before proclaimed — the whole phase is driven by an offline CI-runnable evaluation harness (local BGE embeddings, embedded ONNX cross-encoder, deterministic extractive generation, labelled judge — zero network, zero API key).
- Evaluation harness (C1, plan §9) — versioned golden dataset
examples/rag/eval/golden.yaml(7 cases over a 10-document corpus, incl. the seeded hard-retrieval caseq-007taggedcorrectif), deterministic retrieval metrics (recall@k, precision@k, MRR), generation metrics via LLM-judge with deterministic heuristic fallback (the mode used is always labelled),IRagEvaluator/IRagEvalHarness, agent toolrag_eval, CLIorkeon rag eval --dataset … [--profile|--compare] [--offline] [--min-recall --min-mrr], dedicated workflow.github/workflows/rag-eval.yml. - Hybrid retrieval (C2, plan §5) — in-process
Bm25Index+ReciprocalRankFusion(RRF k=60) behind theHybridSearchDocumentStoredecorator (works over all 6 memory providers; in-process index memory documented), native provider hybrid preferred via the newIHybridSearchCapableDomain capability (LanceDB), native scores viaIScoredVectorSearch(ChromaDB/Pinecone/LanceDB). - Reranking (C3, plan §7) — opt-in packages
Orkeon.Rag.Onnx(cross-encoder runtime, ms-marco-MiniLM-L-6-v2, Apache-2.0) +Orkeon.Rag.Onnx.Model(int8 weights embedded — guaranteed offline, no download ever) registered withAddOrkeonOnnxReranker();LlmListwiseReranker(universal fallback over the 12 providers) andNoopReranker; default cascade CandidateK 50 → TopN 5. - Staged pipeline + profiles (C4, plan §5.1–5.2) —
StagedRagPipelinereplacesLinearRagPipeline(breaking rename, beta window): transform (hooknone, RAG-05) → retrieve (CandidateK, per-query hybrid) → fuse (RRF/dedup) → rerank (named,RerankerFactory) → assemble (token budget + anti-Lost-in-the-Middleedgesordering: ranks 1, 3, 5… open the context, …6, 4, 2 close it — best two chunks at the extremities, rank-stable[n]markers) → cited generation → optional groundedness hook (checker ships with RAG-06); every stage traced inRagAnswer.Trace.RagProfile { Fast, Balanced, Quality }+RagProfilePresets→RagOptionsv2 bound onOrkeon:Rag(profile = preset, configuration = per-key override; defaults inOrkeon.Domain.Constants.Rag.RagDefaults);ProfileRagPipelineResolverbuilds and memoizes one pipeline per profile (unknown names fail loudly listingfast, balanced, quality, default). Default profile isfast(deviation from plan §5.2'sbalanced: balanced requires the opt-in ONNX package; opt in with one keyOrkeon:Rag:Profile=balanced). The store is now always wrapped in the hybrid decorator (ingestion feeds BM25; disabled default mode = strict passthrough);RetrievalQuery.Hybridtoggles fusion per query. - Measured comparison (golden dataset, offline, heuristic judge, 2026-07-26): fast 0.89 recall@5 / 0.89 MRR / 3 ms/case; balanced 0.89 / 0.89 / 139 ms/case; quality 0.89 / 0.89 / 105 ms/case (9 cases, incl. two exact-identifier lookups q-008/q-009 that vector-only also resolves at this corpus scale). The three profiles tie on this dataset by construction: the eight regular cases are saturated by plain vector retrieval (1.00/1.00 each,
correctifexcluded ⇒ gated aggregates 1.00/1.00 for all profiles), and the seededq-007defeats the cross-encoder too (measured score of the relevantnotes-power.md: 0.0000, dead last; decoyfaq-battery.md: 0.9997) — vocabulary bridging is exactly the RAG-05/RAG-06 lever (plan §9.1:correctifmust fail until Corrective). The CI gate runs on the balanced profile and the fast/balanced/quality table is published in the workflow step summary.
Changed — BREAKING: RAG subsystem extraction (RAG-02, no shims)
The RAG feature set is promoted to a first-rank subsystem (src/rag/ — Orkeon.Rag.Abstractions contracts + Orkeon.Rag implementations, agent tools in src/tools/Orkeon.Tools.Rag; see ADR-006). The legacy namespaces Orkeon.Application.Interfaces.Rag.*, Orkeon.Application.Rag.*, Orkeon.Application.Interfaces.Knowledge.* and Orkeon.Infrastructure.Knowledge.* are removed without [Obsolete] shims (assumed break, decision 2026-07-25, 0.9.x-beta window).
Opt-in wiring: services.AddOrkeonRag(configuration) (namespace Orkeon.Rag.DependencyInjection, self-sufficient TryAdd*, default IDocumentStore = MemoryProviderDocumentStore over the ambient IMemoryProvider) + services.AddOrkeonRagTools() (Orkeon.Tools.Rag.DependencyInjection, registers rag_search). Neither is called by AddOrkeonInfrastructure().
Migration table (old type → new type):
| Old (removed) | New |
|---|---|
Orkeon.Infrastructure.Knowledge.RagTool (rag_search) |
Orkeon.Tools.Rag.RagSearchTool (rag_search — same name, schema question/top_k/collection, and output format answer + Sources: block; collection = "raggable-tree" still routes to IRaggableStore) |
AddOrkeonRag (Infrastructure RagServiceExtensions) + AddOrkeonKnowledge + AddOrkeonRagValidation |
AddOrkeonRag(configuration) (Orkeon.Rag.DependencyInjection.RagServiceCollectionExtensions — loaders + ingestion validation + pipelines + factories) + AddOrkeonRagTools() |
Orkeon.Application.Interfaces.Rag.IRagPipeline (ExecuteAsync(question, RagOptions) → RagResult) |
Orkeon.Rag.Abstractions.Interfaces.IRagPipeline (QueryAsync(RagQuery) → RagAnswer with citations + trace) |
Orkeon.Application.Rag.RagPipeline + ChatClientResponseGenerator |
Orkeon.Rag.Pipeline.StagedRagPipeline (named LinearRagPipeline until RAG-04/C4) |
KnowledgeService (ingestion side) |
Orkeon.Rag.Pipeline.DefaultIngestionPipeline (IIngestionPipeline) |
IKnowledgeService (Application port) |
Orkeon.Rag.Abstractions.Interfaces.IDocumentStore (storage/search) + IIngestionPipeline (ingestion) + IRagPipeline (query) |
TextFileLoader / CsvDocumentLoader / HtmlDocumentLoader / PdfDocumentLoader / DocumentLoaderFactory (Infrastructure.Knowledge.Loaders) |
Orkeon.Rag.Loaders.* (same names, IDocumentLoader over SourceDescriptor → RagDocument) |
WebPageLoader (Infrastructure.Knowledge.Loaders) |
Orkeon.Rag.Loaders.WebPageLoader (typed HttpClient, kinds url/web) |
RecursiveTextChunker / SentenceChunker (+ tool-local chunker copies) |
Orkeon.Rag.Chunking.* — IChunkingStrategy implementations recursive, sentence, structural, semantic |
ContentIntegrityValidator / PromptInjectionDocumentValidator / DataValidationPipeline / ProvenanceTracker / IQuarantineStore + InMemoryQuarantineStore (Infrastructure.Knowledge.Validation) |
Orkeon.Rag.Validation.* (same names — validation stays on the ingestion path) |
AnalysisEmbeddingProviderAdapter (Infrastructure.LLMs.Embeddings) |
Orkeon.Rag.Embeddings.AnalysisEmbeddingProviderAdapter |
SimpleEmbeddingService (hash-based, [Obsolete]) |
Removed without replacement. The default IEmbeddingService now adapts the IEmbeddingProvider port (EmbeddingProviderServiceAdapter): local BGE → remote Orkeon:Embeddings → fail-fast at first use. Semantic agent selection inherits the real chain. |
HybridScorer (Infrastructure.Knowledge.Retrieval, dead code) |
Removed without replacement (hybrid search capability lives in Orkeon.Domain.Memory.IHybridSearchCapable) |
FileKnowledgeSource / DirectoryKnowledgeSource / WebKnowledgeSource / DatabaseKnowledgeSource (Infrastructure.Knowledge.Sources) |
Removed without replacement — describe sources with SourceDescriptor and run them through IIngestionPipeline (IngestionRequest). The Domain contract Orkeon.Domain.Knowledge.IKnowledgeSource remains (no framework implementations). |
RagOptions / RagPipelineOptions / RagDefaults / KnowledgeContext / KnowledgeItem / RagTypes (RagResult, RetrievalOptions…) |
Orkeon.Rag.Abstractions.Models.* (RagQuery, RagAnswer, Citation, ScoredChunk, RetrievalQuery…) + Orkeon.Rag.Abstractions.Options.RagOptions v2 (section Orkeon:Rag, RAG-04/C4) / RagIngestionOptions (section Orkeon:Rag:Ingestion); defaults in Orkeon.Domain.Constants.Rag.RagDefaults |
ResearchFindings (was in Orkeon.Application.Rag) |
Kept (not RAG) — moved to Orkeon.Application.Services.Generic |
0.9.2-beta - 2026-07-24
First version actually published to GitHub Packages since 0.9.1-beta (2026-07-04): the intermediate v0.9.1-beta.rc* tags re-packed the unchanged 0.9.1-beta version from Directory.Build.props, so --skip-duplicate silently skipped every push. This release bumps the props version so the feed picks up everything below.
Added
IFileSystemScope(Orkeon.Domain.FileSystem) — ambient per-scope mount override for the VFS.ILlmDeltaSink(Orkeon.Application.Interfaces.Ports) — streaming delta sink port for LLM output.
Security
- A2A mTLS server now authenticates the client certificate instead of merely checking its dates (SEC-012, R9.1). With
RequireMutualTls = true, an incoming certificate must chain to one ofA2ASecurityOptions.TrustedCertificateAuthorities(X509CustomRootTrustchain — also covers validity dates, removing the lastDateTime.Nowinsrc/) or match the newTrustedClientCertificateThumbprintspin list; unpinned self-signed certificates are rejected (403). Starting the server withRequireMutualTlsand no trust anchor now throws (fail-closed) — previously any date-valid certificate passed the guard. Revocation is not checked (private CAs without CRL/OCSP assumed). - A2A client-side CA pinning no longer bypasses host-name validation (SEC-011, R9.1).
A2ASecurityHandlerFactoryonly vouches forRemoteCertificateChainErrors(private CA unknown to the OS store);RemoteCertificateNameMismatch/RemoteCertificateNotAvailableare never accepted. The explicitValidateServerCertificate = falseopt-out now logs a security warning (local development only). - A2A mTLS handler is built once and cached instead of per call (ANT-018, R9.2). On every A2A call (
send,sendSubscribe,cancel,status) the client used to re-read the PFX through the VFS, re-import theX509Certificate2(never disposed) and create a fresh handler+client — a full mTLS handshake per call.A2ASecurityHandlerFactorynow returns a pooledSocketsHttpHandler(SslOptions.ClientCertificates,PooledConnectionLifetime2 min) cached lazily byA2AClient; per-call clients wrap it withdisposeHandler: false.A2AClientis nowIDisposableand disposes the handler and the imported certificate exactly once. Certificate rotation requires a new client instance (options snapshot at construction).
Changed
Public API reshaped to .NET design-guideline conformance; 8 API-shape rules frozen as build errors (R11.7 / maintainer decision D1 = "fix everything", breaking, 0.9.0-beta). The API-shape analyzer family was driven to zero across all
src/with noseverity = nonecarve-out:- Exposed collections are read-only (CA1002/CA2227/CA1819):
List<T>/T[]properties and returns becomeIReadOnlyList<T>; settable collection properties becomeinit/get-only. Deserialization-safe by construction —IConfiguration-bound options useCollection<T>get-only, YamlDotNet DTOs keep a settableCollection<T>?, System.Text.Json DTOs useIReadOnlyList<T>init; graph/builder state keeps a private mutable backing field exposed read-only. - URLs are
System.Uri(CA1054/CA1056): string URL parameters and properties acrossIHttpClient,IA2AClient,LlmConfig.BaseUrl, options and tool DTOs now useSystem.Uri. - Cross-language-safe naming (CA1716): interface/virtual parameters and members renamed off reserved keywords (
ISpecification<T>.And/Or/Not→AndWith/OrWith/Negate,IAgentRegistrationStore.Get→GetById), and theOrkeon.Domain.Sharednamespace renamed toOrkeon.Domain.SharedKernelsolution-wide (it collided with the VBSharedkeyword). - Getters and nesting (CA1024/CA1034): getter methods (
GetX()) become properties; public nested types are un-nested (with a qualifying rename where the bare name was generic, e.g.OrkeonDiagnostics.Tags→OrkeonDiagnosticTags) or madeinternalwhen they are implementation details.
All changes are behaviour-preserving (order, JSON wire format, and config/YAML binding verified empirically per layer).
dotnet build Orkeon.slnstays at 0 warning / 0 error. Note: this is a deliberate breaking change to the public surface, taken inside the 0.9.0-beta window before the first NuGet tag; some test assertions (URL equality, null-argument exception types, read-only collections) are updated accordingly and validated on the Windows test run.- Exposed collections are read-only (CA1002/CA2227/CA1819):
Redundant default-value initializers removed and
System.Randomaudited acrosssrc/; CA1805 + CA5394 frozen as build errors (R11.5, zero-warning campaign hygiene wave). The 89 src fields/auto-properties explicitly initialized to their default value (= 0/false/null/default/new()) had the redundant initializer removed (CA1805, mechanical, no behaviour change). The 3 srcSystem.Randomuses flagged by CA5394 are all provably non-security (a SHA256-seeded deterministic pseudo-embedding fallback, retry-backoff jitter, and a simulated research-confidence score) and now carry a tight justified#pragma warning disable CA5394; the rule is frozen aserrorso any newRandomuse trips the build and gets a security review. CA1822 (mark members static) was intentionally deferred — several flagged members are exposed to JS scripts via Jint instance reflection and making them static would break the scripting API.dotnet build Orkeon.slnstays at 0 warning / 0 error.Logging converted to the
LoggerMessagesource generator across allsrc/; CA1848 + CA1873 frozen as build errors (R11.3, zero-warning campaign wave B3). The 113 srcILogger.Log*call sites flagged by CA1848 (use the LoggerMessage delegates) are now[LoggerMessage]source-generated partial methods (per-class EventIds, message templates and structured placeholder names preserved verbatim, exceptions passed as method arguments), and the 34 CA1873 sites (arguments evaluated even when the level is disabled) are fixed by that conversion or by hoisting the expensive expression into a local inside anIsEnabledguard. The single dynamic-level audit sink uses cachedLoggerMessage.Definedelegates. Both rules are locked aserrorforsrc/**. Behaviour is unchanged (same levels, templates, args, exceptions); tests andexamples/keep their occurrences and are not frozen.dotnet build Orkeon.slnstays at 0 warning / 0 error.Externally-visible method parameters are null-guarded across all
src/and CA1062 frozen as a build error (R11.2, zero-warning campaign wave B2). Every externally-visible method that dereferenced a reference parameter without checking it for null now guards it — 605 unique src sites getArgumentNullException.ThrowIfNull(param)as their first statement (the netstandard2.0 analyzer project uses the classicif (x is null) throwform), and CA1062 is locked aserrorforsrc/**so no public entry point can ship unguarded. Body-only edits, no signature/behaviour change: expression-bodied methods became block bodies, constructor-initializer dereferences use(param ?? throw …), and the contractually-nullable null-tolerant JS-facing logging shims coalesce (?? JsValue.Undefined) instead of throwing (a throw there would have regressed their documented null-tolerance). Tests andexamples/(separate solution / test doubles) keep their occurrences and are not frozen.dotnet build Orkeon.slnstays at 0 warning / 0 error.Globalisation analyzer family resorbed to zero across all
src/and frozen as build errors (R11.1, zero-warning campaign wave B1). The culture-sensitive string-operation rules CA1307/CA1310 (StringComparison), CA1305/CA1304 (IFormatProvider/CultureInfo), CA1311 (culture-aware case) and CA1308 (ToLower) are fixed at 363 unique sites and locked aserrorforsrc/**in.editorconfig, so framework code can never silently reintroduce one. Arbitrage "Ordinal default + protect ToLower": comparisons →StringComparison.Ordinal(OrdinalIgnoreCasefor identifier/key matching), formatting →CultureInfo.InvariantCulture, and the 91ToLowerInvariant()sites that produce a wire/storage/switch value keep their lowercase form under a tight justified#pragma warning disable CA1308(6 comparison-only sites rewritten to ordinal-ignore-case). The same six rules are neutralised (severity = none) intests/.editorconfig— test assertions compare literal values where ordinal is already the default — a documented arbitrage, not a suppression. These rules are outside the default analysis set, so enabling them as errors enforces them in the normal build withoutAnalysisMode=All;dotnet build Orkeon.slnstays at 0 warning / 0 error.Pinned Terminal.Gui to 2.0.1 (was 2.1.0). v2.1.0 shipped 2026-05-08 with a major API redesign + rendering bugs (gray-on-gray default scheme, focus on read-only widgets, missing
FakeDriverfor tests). 2.0.1 is the last stable release before that redesign. Same code compiles unchanged (API surface compatible). The xUnitModuleInitializercrash (TUI-12) exists in both versions, so view-touching tests stay skipped.
Fixed
- Null-argument contracts reconciled with the R11.2 guards across Domain/Application/Scripting tests (R11.2 follow-up, surfaced by the full Windows test run). Six tests and one value object that still encoded pre-guard behaviour are aligned with the
ArgumentNullException.ThrowIfNullguards added in R11.2:ValidationResult.Combine,TypedTaskContext.Transform,AgentMapper.ToDto/CreateFromRequestandSequentialCrewOrchestrator.KickoffAsyncnow correctly reject null (the orchestrator'sCrewInputhas no empty form, so a null input is genuinely invalid — the test that expected "graceful" handling now expects the throw).TaskDescription.Fromis the one behavioural fix: the R11.2ThrowIfNullhad fragmented its validation so a null value surfacedArgumentNullException("Value cannot be null") instead of the value object's unifiedArgumentException("… cannot be null or whitespace") used for empty/whitespace — it now validates null/empty/whitespace uniformly in one guard (still CA1062-clean). A telemetry test (OtelTests.tool_call_emits_a_span_with_tool_name_tag) was also made robust against the process-globalActivityListenerpicking up a concurrent test's tool-call span, by filtering on the uniquetool.nametag like the sibling crew/agent span tests already do.dotnet build Orkeon.slnstays at 0 warning / 0 error. - Tool URL parameters typed as
System.Urino longer break the agent tool contract (R11.7-D1 follow-up). The D1 API-shape wave (CA1054/CA1056) converted tool request/response URL properties toSystem.Uri, but the typed-tool schema generator mappedUrito JSON type"object"— so every tool call carrying a string URL was rejected before execution withParameter 'url' has invalid type. Expected: object(≈30 direct failures cascading acrossweb_scrape,scrape_element,http_api,cache_searchandarcadedb_query). Fixed at a single point rather than reverting the DTOs:ToolSchemaGeneratornow mapsUrito"string"(formaturi) likeGuid/DateTime, and a new tolerantUriTolerantConverter(registered only in the component pipeline's options) round-tripsUri↔string — empty/whitespace maps tonull(so a tool's own "URL cannot be empty" validation reports a friendly error instead of an opaque JSON failure), relative values are accepted (UriKind.RelativeOrAbsolute, e.g. a host-substring filter), and writes useOriginalStringto preserve the exact URL with no trailing-slash canonicalisation. TheUrityping (and the frozen CA1054/CA1056 errors) are kept. The samestring→Uri?conversion had also silently flipped the requiredurlparameter ofweb_scrape/scrape_element/http_apito optional in the generated schema (a non-nullablestringinfers required; a nullableUri?infers optional); those three inputs are marked[FieldSchema(IsRequired = true)]to restore the pre-D1 schema while keeping the property nullable for the tool's own emptiness check (cache_search's URL substring filter stays optional;arcadedb_query'sbolt_uriwas already required). Also corrected three test concerns surfaced by the same Windows run: theMockHttpMessageHandler/TestHttpMessageHandlerdoubles now capture a buffered clone of each request so post-send body assertions survive the provider's R10.2 content disposal (wasObjectDisposedException×27), theIUrlValidatorSSRF stub recordsOriginalStringinstead of the canonicalised form, and threeLlmBasedManagernull-argument tests now expect theArgumentNullExceptionthe R11.2 guards correctly throw (wasNullReferenceException).dotnet build Orkeon.slnstays at 0 warning / 0 error; the test suite is re-run on Windows. - Conversation roles are matched case-insensitively everywhere (SML-009, R12.5). The HTTP providers compared
msg.Role == "assistant"(case-sensitive) whileConversationPolicyusedOrdinalIgnoreCase, so a mixed-case role like"Assistant"was serialized one way and policy-matched another. A newLlmRoles(canonical lowercase wire values + a singleIs/IsXhelper) now routes every comparison and message construction in the Anthropic/OpenAI-compatible providers,ConversationPolicy, and theLlmMessagefactories. This fixes a real bug where a mixed-case"Tool"orphan survived history trimming and produced the orphantool_resultthe trim exists to prevent. The scripting default models (LlmNamespaceBinding) were de-duplicated (they were defined twice in the same file); reconciling them withProviderDefaultsis a maintainer product decision (the values differ). release.ymlandci.ymlno longer publish divergent NuGet perimeters (OSS-011, R8.3).release.ymlwas missingOrkeon.Infrastructureeven though the README documents installing it; both workflows now pack the same three core libraries. The published set is documented indocs/reference/publication-matrix.md(promotion of the tools family /orkeontool is held until decision D3 so the scripting twins' names are not locked into NuGet before a possible rename). Stale metadata fixed: theCONTRIBUTINGclone/upstream URLs (Orkeon/orkeon), theOrkeon.ConsoleApplocal1.0.0version override (now inherits0.9.0-beta), and the obsolete+orkeoncoverage-filter comment.HttpRequestMessage/HttpResponseMessageare disposed on the LLM request path (ANT-006, R10.2). The 20 per-call CA2000 leaks in the Anthropic/OpenAI-compatible/Ollama providers andHttpClientAdapterare fixed by real lifetime:using varfor non-streaming request/response, dispose-after-send for the streaming request (the response escapes but the request body is already transmitted), and the previously-leaked cloned retry request inExecuteHttpRequestAsync. The 11LlmProviderFactorysites are ownership transfers (the provider is wrapped in the returned adapter; itsDisposeis a no-op) — collapsed into one genericAdapt<T>helper carrying a single justified suppression.- Semantic memory recall no longer silently returns empty on the default configuration (MAT-017, R10.1).
IMemoryProvider.SearchSimilarAsyncwas a default interface method returning empty, andInMemoryProvider's real cosine implementation was unreachable through the interface (C# does not re-map derived members onto a base-implemented interface).SearchSimilarAsyncis now an abstract member ofMemoryProviderBase(compiler-enforced for every provider), the four affected providers re-list the interface, and Redis/ChromaDB/Pinecone — which had no implementation at all — gained real vector searches (server-side query for Chroma/Pinecone, client-side cosine for Redis). A reflection test locks the interface map for future providers. ICodeSandboxresolution no longer launches adocker versionprocess under the DI singleton lock (ORG-012, R10.3). The availability probe lives in a memoized lazy decorator (LazyProbingCodeSandbox) and runs at the firstExecuteAsync(async, cancellable); the probe process is now killed on timeout/cancellation. Fail-closed semantics of the sandbox gate are preserved byte-for-byte. An architecture test bansGetAwaiter().GetResult()/.Resultinsrc/DI factories (single documented exemption: plugins).- Script command loading no longer blocks the DI thread (ANT-002, R10.3).
ScriptCommandRegistryresolution is pure wiring; discovery + esbuild transpilation + Jint evaluation are deferred to a memoized task awaited at runner startup (failures are not memoized — next call retries). - Script
ctx.services.get("tools")no longer materializes a new set of transient disposable tools per call (ANT-005, R10.4). The whitelist resolves the tool set once (thread-safe lazy) and serves a fresh shallow copy of the same instances — unbounded memory growth in long REPL sessions is gone. MemoryProviderFactoryno longer creates bareHttpClients (ANT-013, R10.5). Chroma/Pinecone/LanceDB clients useSocketsHttpHandlerwithPooledConnectionLifetime(2 min) so rotating cloud endpoints are re-resolved; ChromaDB and Pinecone providers gained the missingDispose(the client was never released) and all three providers are nowIDisposable.CrewOutput.TokensUsedis real telemetry for all 6 process types (MAT-004, R10.8). Parallel/Hierarchical/Consensual/Autonomous now record token usage (new thread-safeTokenUsageTally), Graph propagates its existing internal count (success and circuit-breaker paths), and the prompt/completion split is extracted fromUsageDetailsinstead of being discarded. Breaking (0.9.0-beta):TokensUsedis now nullable —nullmeans "not measured", never a fabricatedTokenUsage(0,0,0); the persisted checkpoint projection recordsTokensMeasuredso the distinction survives round-trips.- Application placeholders implemented or removed (MAT-018, R10.9).
CrewConfigurationMapper.ToConfigurationexports agents and tasks for real (round-trip tested; breaking: the caller now provides the materialized entities);CrewValidatorvalidates LLM configs (model + canonical numeric bounds);CrewPlannerconsumes its previously-ignoredstrategyparameter; the stubAgentPlannerServiceis explicitly documented and logs at use. Removed:IMemoryCoordinator.ClearTemporaryMemoriesAsync(no production caller, no "temporary" marker in the model — the no-op could not be made honest). AddOrkeonA2A()/AddOrkeonInfrastructure()call order no longer matters for the A2A agent directory (ANT-019, R9.3).IAgentRepositoryis now registered withTryAddScopedby the infrastructure: when A2A ran first, itsSharedStoreAgentRepositoryupgrade used to be silently won back by the laterAddScoped(per-scope empty directory — ANT-001's failure mode, no crash). Side effect: a host repository registered before the Orkeon extensions is no longer shadowed by the in-memory default; hosts that overrideIAgentRepositoryafterAddOrkeonInfrastructure()withoutReplacekeep the last-wins behaviour as before.- Ctrl+C in TUI cancels the current command instead of being intercepted as SIGINT (TUI-20) —
Console.TreatControlCAsInput = trueis set afterApplication.Initso Ctrl+C reaches Terminal.Gui's input loop. The .NET runtime'sConsole.CancelKeyPresshook is skipped in TUI mode (would race with the keystroke path). A globalApplication.KeyDownhandler inTerminalGuiHostis the source of truth: 1×Ctrl+C requestsIInteractiveRunner.RequestCommandCancellation(with REPL-pane feedback⏹ Cancellation requested...); 2×Ctrl+C within 2s force-quits the TUI as an escape hatch.RunOneShotAsyncnow accepts anexternalCtparameter soVerifyCommandpropagates the per-command CT into the inner crew kickoff. - Ctrl+Q during a running command shows a confirmation dialog — uses
MessageBox.Query(Terminal.Gui's idiomatic modal). The previous customQuitConfirmDialogexposed a v2 runnable-stack race that swallowed the post-dialogApplication.RequestStop. - Inner-host logs (RunOneShotAsync) leaked to stdout in TUI mode (TUI-19) — when
verifyspawned a child host withAddSimpleConsole, those writes hitSystem.Console.Out(commandeered by Terminal.Gui's alt-screen) and dumped on shutdown. NewOrkeon.Cli.Abstractions.Logging.AmbientLoggerProvideris published by the TUI host on Initialize;RunnerExecution.ConfigureVerboseLoggingresolves it via reflection (no project coupling) and substitutes it forAddSimpleConsolewhen present. Inner host logs now flow into the logs pane. - Default
LoggerFactoryminimum level too restrictive in TUI — runner now setsLogLevel.Traceupstream so all entries reachTerminalGuiLoggerProvider; visible filtering is owned by the provider (toggled at runtime via F2 / Shift+F2 in the status bar). - Gray-on-gray invisible text + missing show/hide logs — Terminal.Gui default scheme paints fg and bg in the same gray, so panes appear empty until you select with the mouse. New
Orkeon.Cli.TerminalGui.Layout.SchemeFactorybuilds explicit white-on-black schemes wired into LogsPaneView/ReplPaneView/SplitPaneToplevel.Ctrl+Gnow toggles the logs pane visibility (REPL fills the screen when hidden). - Initial focus landed on the read-only logs pane instead of the prompt input (TUI-16) —
_history.CanFocus = falseremoves it from the focus cycle, plus anInitializedhook onReplPaneViewcalls_input.SetFocus()once the view is laid out so typing works immediately without clicking. - Status bar shortcuts swallowed by focused TextField (TUI-17) — every Shortcut now sets
BindKeyToApplication = trueso keystrokes route at app level regardless of focus. - Ctrl+Q hung the host when the runner was blocked in
Console.ReadLine(TUI-17) — sync-over-async wrapper passedCancellationToken.None.ReplPaneView.CancelPendingRead()is invoked from the host's finally block to forcibly complete the pending TCS withnullso the runner's loop seesReadLine() == nulland exits. - Hosts hung when the runner ignored cancellation during a long command — added a 2s grace period after
linkedCts.Cancel(); if the REPL task doesn't honour cancellation within the window, it's abandoned (orphaned task finishes in background, host exits cleanly). - Terminal.Gui split-pane silently exited at startup (TUI-14) —
TerminalGuiHost(options, loggerProvider)formed a cycle with theTerminalGuiLoggerProviderDI factory;.Host.Build()exited with code 0 before any UI was rendered.TerminalGuiHostnow takes onlyTerminalGuiOptions; the status bar is built and attached from theTerminalGuiLoggerProviderfactory once both exist. Also: explicitDOTNETdriver on Linux/macOS (TUI-13), since the default driver emits no output in WSL.
Added
- Bilingual documentation parity gate (OSS-012, R8.4).
scripts/check-docs-parity.shfails CI when anydocs/**.mdlacks itsdocs/fr/mirror (or vice versa), or when a rootREADME/CONTRIBUTING/CODE_OF_CONDUCT/SECURITY.mdlacks its.fr.mdpair; wired as thedocs-parityjob. The rule is documented in bothCONTRIBUTINGfiles. - Blocking npm vulnerability audit (DEP-010, R8.6).
dependency-audit.ymlgains annpm-vulnerability-auditjob (npm ci --dry-runintegrity +npm audit --audit-level=highover the esbuild bootstrap), so a CVE onesbuild/@esbuild/*fails CI instead of being an ignorable Dependabot PR. NuGet lockfile pinning was considered and deferred (bump friction outweighs the reproducibility gain already covered by Dependabot + the blocking audit). - Native tool calling for Azure OpenAI (FON-011, R10.7).
AzureOpenAILlmProvideris rebased onOpenAICompatibleProviderBase(its hand-rolled pipeline only readchoices[0].message.content) and the factory passes the OpenAI strategy:tools/tool_choiceare injected andtool_callsparsed natively, with the text fallback kept as the safety net. Ollama stays on the text protocol — its/api/generatepipeline is prompt completion and the native-tools/api/chatresponse format is incompatible with the OpenAI parser; documented indocs/reference/limitations.md. runCrewscript calls are bounded by a configurable timeout (ANT-007/ANT-010, R10.10). Default 10 minutes (ScriptHostFacadeOptions.RunCrewTimeout, ≤ 0 disables): an infinite crew no longer freezes the REPL; scripts get a clearTimeoutException.IConsoleAdaptergainsReadLineAsync/ReadKeyAsyncas non-breaking default interface methods, with real TUI overrides on the existing TCS machinery. The assumed-blocking design (Jint is synchronous) is documented in the scripting guide (EN+FR).- Terminal.Gui split-pane console (
Orkeon.Cli.TerminalGui) — newIConsoleAdapter+ILoggerProviderthat route REPL I/O andILoggerwrites into separate panes (logs on top, REPL on bottom), driven by Terminal.Gui v2.0.1. All 4 interactive runners (ClaimVerifierRunner,MainMenuRunner,QaRunner, plus the new--uiflag inOrkeon.ConsoleApp+examples/runners/interactive-claim-verification) accept--ui tui|plain|auto, defaultauto(TUI when interactive TTY, plain in CI/pipe viaTtyDetector). Wire-up:services.AddOrkeonCliTerminalGui(). Full keybindings: Ctrl+L (clear logs), Ctrl+K (clear REPL), Ctrl+F (find), Ctrl+G (toggle logs pane), Ctrl+↑/↓ (resize split), F2 (more log details) / Shift+F2 (less log details), Ctrl+C (cancel current command — 2× to force-quit), Ctrl+Q (quit, with confirmation dialog if a command is running). TUI-18 tracks the Terminal.Gui 2.1.x re-evaluation follow-up. IInteractiveRunnerinterface inOrkeon.Cli.Abstractions.RunnersexposesIsCommandRunning+RequestCommandCancellation().InteractiveRunnerBaseimplements it; the TUI uses it to drive Ctrl+C cancellation and Ctrl+Q confirmation dialogs.AmbientLoggerProviderinOrkeon.Cli.Abstractions.Logging— process-wide ambientILoggerProviderregistry letting child hosts re-route their logs into a parent TUI's pane without project coupling (resolved via reflection byRunnerExecution.ConfigureVerboseLogging). Wrapped in a non-owningLeasedLoggerProviderso child host disposal doesn't kill the parent's provider.- TUI key event diagnostic (
examples/runners/tui-keytest) — small standalone runner that boots Terminal.Gui and logs every keystroke arriving atApplication.KeyDownto/tmp/tui-keytest.log. Used to diagnose terminal-specific keystroke routing issues (TUI-20). Run withdotnet run --project examples/runners/tui-keytest. - Virtual FileSystem v2.2 — enumeration + streaming surface on
IFileSystemService(EnumerateFilesAsync,OpenReadStreamAsync,TryReadAllBytesAsync,TryReadAllTextAsync,GetEntryKindAsync).FileSystemDiscoverernow goes through the VFS instead of rawSystem.IO. - Virtual paths across the RaggableTree pipeline —
RaggableNode.FilePath→VirtualFilePath, propagated through adapters, tools, DTOs (SourceSlice,SymbolSourceResponse), serializer (bumped to v2.0), and the store. Builder reads source viaIFileSystemService.TryReadAllTextAsync. - Index introspection tools —
index_statusandis_path_indexedlet agents check which virtual roots are indexed and whether a given virtual path is covered (longest-match on overlapping roots).IRaggableStore.GetIndexedRoots()exposes the underlying list.
Removed / Breaking
ImprovedAgentExecutionServicerenamed toAgentExecutionService(SML-008, R12.4) — it is the only implementation ofIAgentExecutionService, so the "Improved" qualifier was meaningless. The value objectVersion(which shadowedSystem.Version) is renamedSemanticVersion. Internal/pre-freeze renames in the 0.9.0-beta window. The deadJsonToolCallParser([Obsolete], no usage, no DI registration) and the emptyJsAgentInstanceplaceholder are removed.--prebuild-indexCLI flag removed from the standard runner. Agents now callindex_codebase(root_path="/src")themselves (optionally gated byis_path_indexed). The flag pre-built an index the agent could not scope; letting the agent index what it needs, when it needs it, removed a whole-tree cost from every run.- Serialization format bump — RaggableTree on-disk cache goes from v1.0 to v2.0 (field rename
FilePath→VirtualFilePath). Existing caches will fail to load and need to be rebuilt.
0.9.1-beta - 2026-07-04
Published to GitHub Packages only, without a dedicated changelog section at the
time; its changes are folded into the 0.9.2-beta entries above. Recorded here
so the version chain has no gap. The v0.9.1-beta.rc* tags that followed
re-packed this unchanged version, so --skip-duplicate silently skipped every
push — the incident that motivated the tag↔version guard in the publish
workflow (see 0.9.2-beta).
0.9.0-beta - 2026-03-27
Added
- Typed pipeline architecture:
ComponentBase<TRequest, TResponse>as the core abstraction for all components, replacingDictionary<string, object>signatures throughout the codebase ToolBase<TReq, TRes>generic tool base class with typed request/response, YAML defaults merging, and output filteringEvaluatorBase<TInput, TResult>andFlowStepBase<TInput, TOutput>typed base classes bridging interfaces with the typed pipeline- 15+ built-in tools: FileRead, FileWrite, WebScrape, HttpApi, JSON, CSV, PDF, XML, Database, GitHub, CodeExecution, and more in dedicated
Orkeon.Tools.*projects SimpleCrewOrchestratorreplacing the Akka.NET actor model with straightforward async/await orchestration- Semantic agent selection using embedding-based similarity to match tasks to the most suitable agent
- Strongly typed configurations:
AgentConfiguration,TaskContext,LlmConfig, and related value objects throughout Domain and Application layers - 5 LLM providers: OpenAI, Ollama, Anthropic, Azure OpenAI, and Groq — all HTTP-based implementations extending
HttpLlmProviderBase - Memory providers: Redis (with vector search), SQLite (long-term persistence), and InMemory (for development and testing)
- Fluent Builder API:
AgentBuilder,TaskBuilder,CrewBuilder, andFluentBuilderFactoryfor ergonomic agent/crew construction - YAML configuration support: full round-trip export/import for agents, tasks, crews, and tool schemas;
[FieldSchema],[ComponentContract], and related attributes for schema generation ToolSchemaGenerator: auto-generates JSON/YAML schemas from typed[FieldSchema]attributes, with$ref-based nested type extraction- Tool validation framework: security validation, rate limiting, and telemetry hooks on every tool execution
- Batch tool execution for parallel tool operations
- Structured tool calling protocol (JSON-based) with
ToolCallRequest<TParameters>andFunctionCallInfo - CQRS pipeline: commands and queries for Agent, Crew, and Task aggregates;
ValidatingCommandHandlerdecorator;UnitOfWorkintegration for post-persistence domain event dispatch - Strongly-typed entity IDs: 28 concrete
EntityId<T>types (ULID-based) —AgentId,TaskId,CrewId, etc. — replacing primitive string identifiers - A2A (Agent-to-Agent) communication protocol:
AgentCard, discovery,AgentCommunicationClient/Server,TaskRouter, mTLS support, and DI integration (subsequently renamed toAgentCommunication/) - Session checkpointing:
IStateStore,CheckpointManager,ResumeEnginewith three backing stores; time-travel checkpoint history with fork, replay, and diff - Cognitive memory system: LLM-powered remember/recall with LanceDB embedded vector store support
- Enterprise auth: Azure AD and OIDC integration, claims-based authorization
- Memory encryption at-rest: AES-256-GCM encrypted Redis and SQLite decorators with key rotation
- DLP (Data Loss Prevention):
PiiDetectorwith 5-channel interceptors and per-channel policy - RAG data validation: integrity checks, injection detection, provenance tracking, and quarantine
- Agent kill switch:
IAgentLifecycleManagerfor controlled agent termination InMemoryAgentMemoryStoreRepository(Infrastructure) for fast in-process agent memory- E2E test project (
Orkeon.Infrastructure.Tests) with 10+ integration tests;IConfigurationwired into test DI container - XML documentation on all public types across all projects (CS1591 enforcement enabled)
ToolCallRequest<TParameters>generic typed tool protocol- Manual mock library for LLM, Knowledge, Memory, Security, MCP, Process, and Tool interfaces — replaces Moq across the entire test suite
- Clean Architecture + DDD audit reports (ADRs) documenting architectural decisions and conformance
Changed
- Complete rename to Orkeon (via the interim Arkeon name) across the entire codebase (solution file, namespaces, projects, docs, HTML, scripts, and examples)
- Infrastructure layer redesigned without Akka.NET: simple HTTP-based implementations, direct
async/awaitservice calls, standard dependency injection replacing the actor model - Domain encapsulation hardened: private/internal constructors on all value objects and aggregate roots;
Restore()factory methods for persistence;IReadOnlyList<T>replacing mutableList<T>on domain types - 15+ anemic domain types converted to immutable records (
init-only properties) - Value objects refactored:
AgentSkill,AgentCapability,AgentSelectionResult,CrewVariables,DomainValueObjects.cssplit into per-feature files; renamed duplicates (MemoryEntity,TypedTaskContext,DelegationToolParameters) - Domain reorganized feature-first: Builders, Templates, Callbacks, DomainEvents, and ValueObjects moved to bounded-context folders;
TrainingScenariomoved to Training BC;A2A/renamed toAgentCommunication/ - Application layer reorganized feature-first: DTOs co-located with feature folders; dead infrastructure port interfaces removed;
KickoffAsyncextracted fromCrewaggregate to the Application orchestrator - Infrastructure layer reorganized by feature/BC: Persistence separated per-aggregate
MemoryRelevanceRankerrenamed toMemoryRelevanceServiceAgentStepstring ID replaced with typedAgentStepIdIRepositorysimplified:GetAllAsyncandIPredicateRepositoryremoved (AP4/R45)IAgent.Toolstyped asIReadOnlyList<IBaseTool>(previously untyped)- CQRS handlers wired into the ConsoleApp via the standard pipeline; DI lifetimes aligned
- Test suite migrated from Moq to manual mocks and from FluentAssertions to xUnit
Assert; test methods renamed toShould_Whenconvention; Handler-level directory organization - Trading tools migrated to typed generic pipeline
TradingToolBase<TRequest, TResponse>; tool definitions extracted to YAML - Examples updated to use Fluent Builder API and Docker Model Runner LLM configuration
- ComponentBase JSON serialization extracted from Domain to Infrastructure (N1)
ShouldRetain/ShouldPromoteToLongTermmade internal to enforce aggregate boundary (R35/R8)EntityMemory/EpisodicMemorymade internal to the aggregate (R8/R35)MemoryItemmutation methods made internal (R35)AgentCapabilities/CrewOutputconstructors privatized (R28)CrewInputconstructor made internal (R10/R28)ToolResult/ToolUsageMetricsconverted to init-only properties (R25)- Validation pipeline wired in:
CreateTaskCommandValidatorhandlesAgentIdvalidation;ValidatingCommandHandlerdecorates the CQRS chain UnitOfWorktry/finally guard added to ensure domain events are dispatched after persistence even on exceptions (R21/R39)ITaskRepositoryscoped documented;SaveChangesAsyncremoved from repository, delegated toIUnitOfWork
Fixed
- Infrastructure
ChatClientadapter no longer overrides caller-supplied LLM configuration - Empty task output in process strategies handled gracefully
IMemoryScopeandIMemoryProviderFactoryregistered in DI (defaults toNullMemoryScope)- LLM DI registration corrected across all examples
- LLM
BaseUrlchanged fromhost.docker.internaltolocalhostin examples - Missing
AgentBuilder/CrewTaskBuilderusingdirectives in email-management and research-assistant examples - ClassicTrading example: raw strings wrapped with Value Object factories;
Agent.AgentIdrenamed toAgent.Id; namespace corrections forLlmConfigandILlmProvider - Duplicate
MemoryProviderConfigDtoremoved (kept inMemory/, removed fromCommon/) - Duplicate
MemoryRelevanceRankerstale reference cleaned up (R43/R50) - Domain event dispatch order standardized; DI lifetimes aligned (N5/N6)
PromptShieldBuildertests fixed afterAgentBackstoryVO migration (null backstory handling)- Post-refactoring compilation errors resolved across Infrastructure project
- CS4014 warning: async Timer callback wrapped with
try/catchand discarded correctly - Null safety and structured logging fixes (CS8604, CA1873) across Application and Infrastructure
- Code quality fixes: cognitive complexity (S3776), unused parameters (S1172), collapsed ifs (S1066), assertion improvements (xUnit2013, xUnit2032), and more
Removed
- Akka.NET dependency and all actor-model code (cluster sharding, distributed data, CRDT,
CollaborationActor) - All TODO comments from the codebase
sonar-project.propertiesfile (caused scanner conflicts; all parameters now passed via CLI)- Legacy
CodeInterpreterTool(replaced bySecureCodeInterpreterTool) GetAllAsyncandIPredicateRepositoryfromIRepository<T>(AP4/R45)- 5 dead infrastructure port interfaces from Application layer (R16)
- Moq and FluentAssertions package references from all test projects
[Obsolete]FunctionCalldictionary property replaced byFunctionCallInfo(T17)- Telemetry infrastructure (
StartSpan→ migrated toStartActivity; unused telemetry packages removed) - Direct Domain usings from ConsoleApp services (R51)
0.1.0-alpha - 2025-09-21
Initial public development snapshot. Core domain model established in C# following Clean Architecture principles, as an independent implementation.
Added
- Initial solution structure:
Domain,Application,Infrastructure,ConsoleAppprojects - Core domain entities:
Agent(Worker, Manager, Observer, Human),Crew,CrewTask IBaseToolinterface and initial tool implementationsILlmProviderwith OpenAI and Ollama HTTP implementations- Basic memory abstractions (
IMemoryProvider) - Initial Akka.NET actor-based agent execution (later replaced)
- Communication protocols: Direct, Broadcast, Consensus, Feedback
- Redis memory provider with vector search
- SQLite persistence for long-term memory
- ClassicTrading example (agent crew for trading workflows)
- Standalone mode (no Redis required)
- Console application entry point