basis 0.8.0

The basis SDK: workspace discovery, run lifecycle, one event stream, and the two seams. No protocol, no transport, no TTY.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
//! Opening a workspace: everything a run should only have to discover once.
//!
//! This is the resolution that used to happen inside `prepare()`, per run —
//! context discovery, model resolution, skill registration, template loading,
//! hook loading, MCP connection. ADR-0010 asked for it to happen once and for
//! runs to be minted from the result, because a twenty-agent fan-out should
//! read `AGENTS.md` once rather than twenty times, and should not open twenty
//! copies of every MCP server.
//!
//! What opening does **not** settle anymore is the process: ADR-0018 moved the
//! provider, the credential, the store policy, and the host's interceptors to
//! [`RuntimeBuilder`](crate::RuntimeBuilder). A workspace either borrows a
//! shared [`Runtime`](crate::Runtime) ([`with_runtime`](WorkspaceBuilder::with_runtime))
//! or carries a recipe for a private one
//! ([`with_runtime_builder`](WorkspaceBuilder::with_runtime_builder)), and the
//! bare `Workspace::open(path)` is the second of those with every default —
//! byte-identical to what it always did.
//!
//! Everything settled here is settled for the life of the [`Workspace`]. What a
//! caller can still change per run lives in [`RunSpec`](super::RunSpec).

use std::{
    path::{Path, PathBuf},
    sync::Arc,
};

use mentra::{ModelInfo, ModelSelector};

#[cfg(feature = "mcp")]
use crate::mcp::{self, McpConfig, connections::McpConnections};
use crate::{
    compaction::Compaction,
    config::{self, Config},
    context::{ContextConfig, SystemPrompt, WorkspaceContext},
    error::RunError,
    event::ContextFile,
    hooks::{self, HookRunner, HooksConfig},
    memory::{self, MemoryConfig},
    run::LoadedSkill,
    runtime::{Runtime, RuntimeBuilder, RuntimeRecipe, dispatch},
    shell::ShellAccess,
    skills::{self, SkillsConfig},
    store,
    templates::{self, Template, TemplatesConfig},
    tools::declared::{self, DeclaredTools, ToolsConfig},
};

use super::{Workspace, WorkspaceReuse, lifecycle::MintPosture, roster::ToolRoster};

/// How a workspace is opened.
///
/// Named a builder rather than a config because it is one: it exists to be
/// filled in and then consumed by [`open`](Self::open). The type mentra calls
/// `WorkspaceConfig` is a different thing entirely — the agent's base directory
/// — and basis sets that from this one rather than exposing it.
///
/// Fields are private because the
/// embedded runtime recipe can hold a credential. `with_*` returns a new
/// value, so a host can keep a half-configured builder and finish it
/// differently per workspace.
pub struct WorkspaceBuilder {
    path: PathBuf,
    runtime: RuntimeSource,
    /// One coherent, sticky switch over every repository/home convention.
    discovery_enabled: bool,
    /// Whether this workspace permits only one independent prepare/resume.
    fresh_only: bool,
    /// Inherited policy, a selector override, or complete host-resolved metadata.
    model: WorkspaceModel,
    context: ContextConfig,
    /// What `config.json` said; `None` means discover it at
    /// [`open`](WorkspaceBuilder::open).
    config: Option<Config>,
    /// The host's own say over the system prompt; `None` is discovery alone.
    system_prompt: Option<SystemPrompt>,
    skills: SkillsConfig,
    memory: MemoryConfig,
    /// Which tools the model is offered (decision D3). `ToolRoster::default()`
    /// unless a caller says otherwise.
    roster: ToolRoster,
    #[cfg(feature = "mcp")]
    mcp: McpConfig,
    templates: TemplatesConfig,
    hooks: HooksConfig,
    tools: ToolsConfig,
    shell: ShellAccess,
    compaction: Compaction,
}

/// The one mutually-exclusive source of this workspace's model.
///
/// A sum rather than parallel optional fields makes last-call-wins exact: a
/// selector and resolved metadata cannot both survive on one builder.
#[derive(Debug)]
enum WorkspaceModel {
    Inherited,
    Selector(ModelSelector),
    Resolved(ModelInfo),
}

/// Where this workspace's runtime comes from: borrowed from the host, or
/// built privately from a recipe, bound to this workspace's path.
///
/// The recipe is boxed because it is two orders of magnitude larger than the
/// `Arc` beside it — a provider, a credential, a history policy, an
/// interceptor list, a command environment and a target map — and every
/// `WorkspaceBuilder` would otherwise carry room for all of it whether or not
/// it holds one.
enum RuntimeSource {
    Shared(Arc<Runtime>),
    Private(Box<RuntimeBuilder>),
    Reusable(Box<RuntimeRecipe>),
}

/// Hand-written for the reason [`RuntimeBuilder`]'s is: the private recipe can
/// hold a credential, and its own `Debug` redacts it.
impl std::fmt::Debug for WorkspaceBuilder {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("WorkspaceBuilder")
            .field("path", &self.path)
            .field(
                "runtime",
                match &self.runtime {
                    RuntimeSource::Shared(runtime) => runtime,
                    RuntimeSource::Private(recipe) => &**recipe,
                    RuntimeSource::Reusable(recipe) => recipe,
                },
            )
            .field("discovery_enabled", &self.discovery_enabled)
            .field("fresh_only", &self.fresh_only)
            .field("model", &self.model)
            .field("context", &self.context)
            .field("config", &self.config)
            .field("system_prompt", &self.system_prompt)
            .field("skills", &self.skills)
            .field("memory", &self.memory)
            .field("roster", &self.roster)
            .field("templates", &self.templates)
            .field("hooks", &self.hooks)
            .field("tools", &self.tools)
            .field("shell", &self.shell)
            .field("compaction", &self.compaction)
            .finish_non_exhaustive()
    }
}

impl WorkspaceBuilder {
    pub fn new(path: impl Into<PathBuf>) -> Self {
        Self {
            path: path.into(),
            // A private default runtime, so the one-repository host never sees
            // the third noun (ADR-0018): `Workspace::open(path)` behaves as it
            // always has.
            runtime: RuntimeSource::Private(Box::default()),
            discovery_enabled: true,
            fresh_only: false,
            model: WorkspaceModel::Inherited,
            context: ContextConfig::default(),
            // Unset, so `open` reads the convention where convention says it
            // is — the same default every other discovery on this builder has.
            config: None,
            // Unset, so the prompt is what the workspace says and nothing else.
            // basis ships no system prompt of its own (PROPOSAL.md Bet 4) and
            // a seam is not a default.
            system_prompt: None,
            skills: SkillsConfig::default(),
            memory: MemoryConfig::default(),
            // D3: today's exact hidden set, so `Workspace::open(path)` offers
            // precisely what it always has.
            roster: ToolRoster::default(),
            #[cfg(feature = "mcp")]
            mcp: McpConfig::default(),
            templates: TemplatesConfig::default(),
            hooks: HooksConfig::default(),
            tools: ToolsConfig::default(),
            // Granted, per ADR-0013, and from the enum's own default rather
            // than from anything ambient: what a run may do is stated here, in
            // configuration, not read out of the environment behind the caller.
            shell: ShellAccess::default(),
            // Keeps every tool result the model was shown, and leaves mentra's
            // summarizing numbers where mentra put them (see
            // [`crate::compaction`]).
            compaction: Compaction::default(),
        }
    }

    /// Borrows the host's runtime instead of building a private one.
    ///
    /// The N-repository shape: one [`Runtime`] built once, every workspace
    /// opened with a clone of the `Arc`. Provider, credential, store, and
    /// host interceptors are the runtime's facts and cannot be re-said here;
    /// what this workspace still decides is what its repository says, plus the
    /// [`with_model`](Self::with_model) override and its command posture.
    pub fn with_runtime(self, runtime: Arc<Runtime>) -> Self {
        Self {
            runtime: RuntimeSource::Shared(runtime),
            ..self
        }
    }

    /// Supplies the recipe for this workspace's private runtime.
    ///
    /// [`open`](Self::open) builds it bound to this workspace's path — the
    /// per-path persist identifier and workspace-bounded policy the bare
    /// `Workspace::open` has always produced — so this is *configuring* the
    /// sugar, not switching shapes. It is also the migration path for every
    /// knob ADR-0018 moved: a one-shot caller that needs an interceptor or a
    /// store directory puts it on a [`RuntimeBuilder`](crate::RuntimeBuilder)
    /// and hands it here.
    pub fn with_runtime_builder(self, runtime: RuntimeBuilder) -> Self {
        Self {
            runtime: RuntimeSource::Private(Box::new(runtime)),
            ..self
        }
    }

    /// Supplies the repeatable private-runtime recipe for consume/rebuild.
    ///
    /// Opening this source is intentionally stricter than an ordinary private
    /// builder: discovery must be disabled, fresh-only must be explicit, and
    /// complete resolved model metadata must be supplied. The resulting
    /// workspace starts unbound and cannot mint until
    /// [`Workspace::bind_host_tools`](crate::Workspace::bind_host_tools)
    /// consumes it with the checkout's complete host-tool set (including an
    /// explicitly empty set).
    ///
    /// The supported reuse proof is intentionally narrower than everything
    /// Mentra can run: a discovery-off host supplies an exact allow-list
    /// roster and does not escape through raw Mentra APIs or execute team,
    /// background, `spawn`, detached custom-tool, or other work whose lifetime
    /// Basis cannot track. Such use poisons reuse where Basis can observe the
    /// escape; the remaining execution limits are part of the host contract,
    /// not inferred cleanup.
    #[must_use]
    pub fn with_runtime_recipe(self, recipe: RuntimeRecipe) -> Self {
        Self {
            runtime: RuntimeSource::Reusable(Box::new(recipe)),
            ..self
        }
    }

    /// Allows exactly one independent [`Workspace::prepare`] or
    /// [`Workspace::resume`] attempt from the opened workspace.
    ///
    /// Subsequent turns on the returned [`crate::PreparedRun`] remain
    /// attached and unrestricted. The claim is irreversible even if the first
    /// attempt fails: without Gate 1b's scrub contract, Basis cannot prove a
    /// partly minted/resumed runtime is clean enough to retry.
    ///
    /// Requires a private runtime recipe. A shared runtime could be minted by
    /// another workspace through a different `Arc`, bypassing this workspace's
    /// gate, so [`open`](Self::open) refuses that ownership shape.
    /// Direct session creation through [`Workspace::mentra_runtime`] is the
    /// raw Mentra escape hatch and is outside this supported Basis lifecycle.
    #[must_use]
    pub fn fresh_only(self) -> Self {
        Self {
            fresh_only: true,
            ..self
        }
    }

    /// Overrides the runtime's model policy, for this workspace alone.
    ///
    /// Unset, the runtime's [`with_model`](crate::RuntimeBuilder::with_model)
    /// policy decides. Either way the *resolved* model is this workspace's
    /// fact, fixed at open and reported by every run it mints.
    pub fn with_model(self, model: ModelSelector) -> Self {
        Self {
            model: WorkspaceModel::Selector(model),
            ..self
        }
    }

    /// Supplies the complete model metadata this workspace must use.
    ///
    /// Unlike [`with_model`](Self::with_model), this is an answer rather than
    /// a selection policy: [`open`](Self::open) does not list or resolve
    /// models. The metadata, including its context window, reaches every
    /// session minted by the workspace unchanged.
    ///
    /// The model must name the same provider as the workspace's runtime. A
    /// mismatch is refused by [`open`](Self::open) before provider or tool
    /// activity with
    /// [`RunError::ResolvedModelProviderMismatch`](crate::RunError::ResolvedModelProviderMismatch).
    /// Calling this after [`with_model`](Self::with_model), or vice versa,
    /// replaces the earlier value.
    #[must_use]
    pub fn with_resolved_model(self, model: ModelInfo) -> Self {
        Self {
            model: WorkspaceModel::Resolved(model),
            ..self
        }
    }

    pub fn with_context(self, context: ContextConfig) -> Self {
        Self { context, ..self }
    }

    /// Disables every repository- and home-discovered input as one posture.
    ///
    /// Opening still validates and resolves the workspace path, and explicit
    /// host inputs still apply: a supplied [`Config`], private runtime recipe
    /// and provider, model, system prompt, native tools, roster, interceptors,
    /// shell posture, and compaction. What stops is file discovery and the work
    /// caused by it: context, config, hooks, declared tools, memory, skills,
    /// templates, and MCP files/connections are not probed.
    ///
    /// Sticky by construction: no source-specific `with_*` setter changes this
    /// private flag, so calling one later cannot accidentally reopen a file
    /// input. Build a fresh builder to restore the default discovery posture.
    ///
    /// This posture requires a private runtime recipe supplied through
    /// [`with_runtime_builder`](Self::with_runtime_builder). A borrowed runtime
    /// is mutable through every other `Arc` holder, while Mentra reads its
    /// runtime-global skill descriptions on every round; refusing
    /// [`with_runtime`](Self::with_runtime) is the only race-free way Gate 1a's
    /// fresh-only lifecycle can guarantee that no later registration widens
    /// the prompt or roster through Basis's builder surface. A caller that
    /// subsequently mutates [`Workspace::mentra_runtime`] has deliberately
    /// left this contract through the raw Mentra escape hatch.
    #[must_use]
    pub fn without_discovery(self) -> Self {
        Self {
            discovery_enabled: false,
            ..self
        }
    }

    /// Supplies the `config.json` answers instead of discovering them.
    ///
    /// Unset, [`open`](Self::open) reads `.basis/config.json` and the global
    /// `config.json` itself, because opening a path is what reads a
    /// repository's conventions — the same reason it reads `AGENTS.md` and
    /// `.mcp.json` without being asked to.
    ///
    /// Two callers want to say otherwise. A host that already discovered a
    /// [`Config`] — to report it, or to apply its process half to a shared
    /// [`Runtime`](crate::Runtime) with
    /// [`RuntimeBuilder::with_config`](crate::RuntimeBuilder::with_config) —
    /// hands the same value here rather than paying for the read twice. And
    /// `Config::default()` says *nothing*, which is how a host that wants its
    /// own configuration to be the only configuration turns the file off.
    ///
    /// Whatever arrives still loses to every explicit call on this builder and
    /// on the runtime's: this is the layer below them, never above.
    pub fn with_config(self, config: Config) -> Self {
        Self {
            config: Some(config),
            ..self
        }
    }

    /// Gives the host a say over the system prompt, for this workspace's runs.
    ///
    /// [`SystemPrompt::Append`] puts the host's text after the discovered
    /// context, as the most specific block; [`SystemPrompt::Replace`] makes it
    /// the whole prompt and leaves discovery out of it. Unset — the default —
    /// the prompt is the rendered context and nothing else.
    ///
    /// Workspace-level and not runtime-level, deliberately: a host serving
    /// several repositories off one shared [`Runtime`] (ADR-0018) can give each
    /// its own voice, and the prompt is settled at
    /// [`open`](Self::open) into the workspace's own `AgentConfig`, so runs
    /// minted from different workspaces cannot pick up each other's.
    ///
    /// One field, so the last call wins — and the enum makes *both at once*
    /// unspellable rather than undefined.
    pub fn with_system_prompt(self, system_prompt: SystemPrompt) -> Self {
        Self {
            system_prompt: Some(system_prompt),
            ..self
        }
    }

    pub fn with_skills(self, skills: SkillsConfig) -> Self {
        Self { skills, ..self }
    }

    /// Sets where memory files are discovered, or turns discovery off.
    ///
    /// Memory is files, not a subsystem — see [`crate::memory`] for the
    /// convention, the two default roots, and what the index costs. Unset,
    /// the convention applies: the global config directory's `memory/`, plus
    /// the sibling `memory/` beside the runtime's store dir when
    /// [`RuntimeBuilder::with_store_dir`](crate::RuntimeBuilder::with_store_dir)
    /// named one. [`MemoryConfig::disabled`] reads nothing at all.
    pub fn with_memory(self, memory: MemoryConfig) -> Self {
        Self { memory, ..self }
    }

    /// Sets which tools the model is offered, for every run this workspace
    /// mints (decision D3).
    ///
    /// Unset, [`ToolRoster::default`] applies: exactly what every workspace
    /// has offered — `spawn`'s replaced doors and basis's never-surfaced
    /// intrinsics hidden, everything else offered. Neither constructor on
    /// [`ToolRoster`] changes what is *registered* on the runtime; see its
    /// module docs for the two things that still apply on top of whatever
    /// roster is set here — a per-mint hide of a sibling workspace's tools,
    /// and the rendered prompt, which has no opinion about the roster at all.
    pub fn with_tool_roster(self, roster: ToolRoster) -> Self {
        Self { roster, ..self }
    }

    /// Sets which MCP servers this workspace connects.
    ///
    /// Servers arrive from three places — the caller's own list, the
    /// workspace's `.mcp.json`, and the global one — and this is where the
    /// first of those goes. See [`crate::mcp`] for the precedence.
    ///
    /// The connections are opened once, by [`open`](Self::open), owned by the
    /// workspace, and shared by every run minted from it — on a shared runtime
    /// they die with this workspace, not with the runtime (ADR-0018).
    #[cfg(feature = "mcp")]
    pub fn with_mcp(self, mcp: McpConfig) -> Self {
        Self { mcp, ..self }
    }

    pub fn with_templates(self, templates: TemplatesConfig) -> Self {
        Self { templates, ..self }
    }

    /// Sets where subprocess hooks are discovered.
    ///
    /// A hook is an external command that gets a say over each tool call; see
    /// [`crate::hooks`] for the wire contract and for what happens when one
    /// breaks. [`RuntimeBuilder::with_interceptor`](crate::RuntimeBuilder::with_interceptor)
    /// is the same say, in the host's process — host scope is runtime scope.
    pub fn with_hooks(self, hooks: HooksConfig) -> Self {
        Self { hooks, ..self }
    }

    /// Sets where declared subprocess tools are discovered.
    ///
    /// A declared tool is a command the workspace offers the *model* as a tool,
    /// with a JSON schema for its input; see [`crate::tools::declared`] for the
    /// manifest and for what a failing one tells the model. The tools are
    /// registered on the runtime this workspace borrows and deregistered — as
    /// far as mentra's registry allows — when the workspace drops, so a
    /// repository's tools never reach another repository's runs.
    pub fn with_tools(self, tools: ToolsConfig) -> Self {
        Self { tools, ..self }
    }

    /// Grants or denies command execution, for every run this workspace mints.
    ///
    /// Granted by default (ADR-0013). Denying is the read-only posture: it
    /// shuts the command tools and nothing else, so it is a narrowing of what
    /// these runs do, never a claim about what the process could do.
    ///
    /// Workspace-level because it is a statement about this repository's runs.
    /// On a private runtime it is baked into the runtime's policy; on a shared
    /// one — whose policy cannot vary per workspace — it is enforced by the
    /// runtime's hook dispatcher, which denies `spawn`'s command mode for this
    /// workspace's agents (see [`crate::runtime`]).
    pub fn with_shell(self, shell: ShellAccess) -> Self {
        Self { shell, ..self }
    }

    /// Sets how much of a conversation reaches the model, for every run this
    /// workspace mints.
    ///
    /// Unset, [`Compaction::default`] applies: every tool result the model was
    /// shown stays in front of it, and mentra's summarizing trigger is
    /// untouched. See [`crate::compaction`] for the two mechanisms and for why
    /// the default is what it is.
    ///
    /// Workspace-level, not runtime-level, and the reason is mechanical rather
    /// than aesthetic. These numbers live on mentra's `AgentConfig`, one is
    /// built per workspace by [`open`](Self::open)'s `agent_config`, and every
    /// session this workspace mints — and every subagent that clones its
    /// config — carries that one. A runtime-level knob would have to be read
    /// back out at the same moment anyway, and could not then be varied per
    /// repository, which ADR-0018's split is precisely about: the runtime owns
    /// what changes when the host changes, and how much history a repository's
    /// runs keep is not that.
    pub fn with_compaction(self, compaction: Compaction) -> Self {
        Self { compaction, ..self }
    }

    /// Does all of it: discovery, runtime acquisition, model, skills,
    /// templates, hooks, MCP connections.
    ///
    /// This is the expensive call, and the only one. Everything it settles is
    /// fixed for the life of the returned [`Workspace`]; a run minted from that
    /// workspace does no I/O of its own.
    ///
    /// # What this workspace's conversations are tagged with
    ///
    /// Every agent persisted from here should carry
    /// [`store::runtime_identifier`](crate::store::runtime_identifier) for this
    /// workspace, which is what makes [`store::list`](crate::store::list) — and
    /// therefore ACP's `session/list` — able to answer *which conversations
    /// belong to this repository*. On a private runtime it does, exactly as
    /// before. On a shared runtime mentra 0.18 can only tag with the
    /// runtime-wide identifier fixed at build (`"basis:runtime"`), so rows minted
    /// there stay out of every per-workspace list until the per-session
    /// override lands upstream — see [`Runtime::mint`](crate::Runtime), which
    /// is the one line that changes. Mis-listing is the whole cost: mentra
    /// loads an agent by id alone, so resuming is unaffected, and an agent
    /// re-tags itself the next time it persists under a runtime that knows its
    /// workspace.
    ///
    /// # What sharing a runtime shares
    ///
    /// Skills are registered on the runtime's single registry, so a skill one
    /// workspace registers is loadable by another's runs — an accepted
    /// consequence of sharing; [`Workspace::skills`] reports only what this
    /// workspace registered. MCP tools live on the same single registry but do
    /// **not** travel: every roster minted here hides the `mcp__*` tools of
    /// servers this workspace does not own.
    pub async fn open(self) -> Result<Workspace, RunError> {
        if let RuntimeSource::Reusable(recipe) = &self.runtime {
            if self.discovery_enabled {
                return Err(RunError::ReusableWorkspaceRequiresDiscoveryOff);
            }
            if !self.fresh_only {
                return Err(RunError::ReusableWorkspaceRequiresFreshOnly);
            }
            let WorkspaceModel::Resolved(model) = &self.model else {
                return Err(RunError::ReusableWorkspaceRequiresResolvedModel);
            };
            if self.roster.as_profile().allowed_tools.is_none() {
                return Err(RunError::ReusableWorkspaceRequiresExactRoster);
            }
            if model.provider.as_str() != recipe.provider().as_str() {
                return Err(RunError::ResolvedModelProviderMismatch {
                    model: model.id.clone(),
                    model_provider: model.provider.as_str().to_string(),
                    runtime_provider: recipe.provider().as_str().to_string(),
                });
            }
        }

        let context = if self.discovery_enabled {
            WorkspaceContext::discover_with(&self.path, &self.context)?
        } else {
            // `none` skips every file candidate but deliberately retains
            // canonical workspace-path validation.
            WorkspaceContext::discover_with(&self.path, &ContextConfig::none())?
        };

        // A shared runtime can acquire a skill loader after any one-time
        // inspection, and Mentra appends that loader's descriptions on every
        // round independently of the agent roster. Reject the ownership shape
        // itself, immediately after the one operation discovery-off retains
        // (workspace validation), so the refusal precedes runtime acquisition,
        // model resolution and all provider/tool/interceptor activity.
        if !self.discovery_enabled && matches!(&self.runtime, RuntimeSource::Shared(_)) {
            return Err(RunError::DiscoveryDisabledSharedRuntime);
        }
        if self.fresh_only && matches!(&self.runtime, RuntimeSource::Shared(_)) {
            return Err(RunError::FreshOnlySharedRuntime);
        }
        let fresh_only = self.fresh_only;

        // Read before the runtime is acquired, for the reason the hooks file
        // below is: a config that does not parse must fail the open rather
        // than let a run reach a model nobody in this repository chose. The
        // global directory is the context config's, so one process cannot read
        // two different global directories.
        let config = match self.config {
            Some(config) => config,
            None if self.discovery_enabled => {
                config::Config::discover(&self.path, self.context.global_dir.as_deref())?
            }
            None => Config::default(),
        };

        // Loaded before the runtime is acquired so a hooks file that does not
        // parse fails the open loudly, rather than at the first tool call —
        // or worse, never.
        let loaded_hooks = if self.discovery_enabled {
            hooks::load(&self.path, &self.hooks)?
        } else {
            Vec::new()
        };

        // Read here for the same reason, and one of its own: a manifest that
        // does not parse is a tool the model's instructions assume and will not
        // find. Registering it needs the runtime, so that waits until there is
        // one.
        let declared_sources = if self.discovery_enabled {
            declared::discover(&self.path, &self.tools)?
        } else {
            Vec::new()
        };

        // Memory, before the runtime is acquired for the reason the files
        // above are — a memory that does not parse fails the open naming the
        // file. The workspace root derives beside the runtime's store dir
        // ([`crate::memory`]), which on the private path is still a recipe, so
        // both shapes are asked before the match below consumes them. The
        // roots are resolved whether or not they exist yet: the private
        // runtime's policy names them (the model writes memories through the
        // ordinary file tools, and the roots sit outside the workspace), and
        // the first memory is written by exactly the run that finds none to
        // read.
        //
        // **`WorkspaceMemoryRoot::BesideStore` resolves only here, on the
        // private path.** A shared runtime's store dir is one runtime-wide
        // fact, not this workspace's — every workspace borrowing it would
        // derive the identical sibling `memory/` directory, and each would
        // read the others' memory index into its own prompt (worse than the
        // write-is-refused gap this replaces: that let the index render
        // anyway). `None` here is what makes `memory::roots` skip the
        // workspace root entirely on a shared runtime, exactly parallel to
        // the dispatcher's existing shared-runtime posture — it can deny, it
        // cannot grant a root the policy never named. The global root is
        // unaffected: every workspace's own memories are exactly that,
        // whichever runtime they borrow. An explicit
        // [`WorkspaceMemoryRoot::Dir`](crate::memory::WorkspaceMemoryRoot::Dir)
        // is unaffected either way — naming a path is the host taking
        // responsibility for it, shared runtime or not.
        let store_dir = match &self.runtime {
            RuntimeSource::Shared(_) => None,
            RuntimeSource::Private(recipe) => recipe.named_store_dir().map(Path::to_path_buf),
            RuntimeSource::Reusable(_) => None,
        };
        // This wave's own I/O — `roots`, the per-file reads `load` does, and
        // the `canonicalize` inside `crate::paths::same_dir` — goes to a
        // blocking thread (whole-wave review, G7): `basis-acp` cold-opens
        // workspaces on its shared runtime, and this is genuinely blocking
        // work the way `spawn_blocking`'s other callers already are
        // (`hooks/runner.rs`, `tools/declared/tool.rs`). The context, hooks
        // and declared-tools discovery just above stay sync on purpose —
        // they predate this wave and are not what it added, so smoothing the
        // asymmetry away here would be a second refactor nobody asked for.
        let memory_config = self.memory;
        let (memory_sources, memories) = if self.discovery_enabled {
            tokio::task::spawn_blocking(move || {
                let memory_sources = memory::roots(&memory_config, store_dir.as_deref());
                let memories = memory::load(&memory_sources)?;
                Ok::<_, memory::MemoryError>((memory_sources, memories))
            })
            .await
            .map_err(RunError::MemoryDiscovery)??
        } else {
            (Vec::new(), Vec::new())
        };
        let memory_roots: Vec<PathBuf> = memory_sources
            .iter()
            .map(|source| source.path.clone())
            .collect();

        let shared = matches!(self.runtime, RuntimeSource::Shared(_));
        let (runtime, reusable_recipe) = match self.runtime {
            // A shared runtime's provider, credential and endpoint are the
            // host's process facts and were settled before this workspace
            // existed, so a file's `provider` and `base_url` have nothing to
            // reach here — the host that shares a runtime is the one that
            // decided the connection, and applies `RuntimeBuilder::with_config`
            // itself if it wants a file to speak for it. What still applies is
            // `model`, below, which ADR-0018 already makes a workspace override.
            RuntimeSource::Shared(runtime) => (runtime, None),
            RuntimeSource::Private(recipe) => (
                Arc::new(recipe.with_config(&config).build_for(
                    &self.path,
                    self.shell,
                    &memory_roots,
                )?),
                None,
            ),
            RuntimeSource::Reusable(recipe) => {
                let runtime = Arc::new(
                    recipe
                        .build_for(&self.path, self.shell, &memory_roots)
                        .await?,
                );
                (runtime, Some(recipe))
            }
        };

        // The workspace's own override first, then the file, then the runtime's
        // policy — which on the private path is already the file's answer, so
        // the two agree by construction rather than by luck. A resolved model
        // is already the final answer: preserve it whole and never consult the
        // provider's catalogue.
        let model = match self.model {
            WorkspaceModel::Inherited => runtime.resolve_model(config.model_selector()).await?,
            WorkspaceModel::Selector(selector) => runtime.resolve_model(Some(selector)).await?,
            WorkspaceModel::Resolved(model) => {
                if model.provider.as_str() != runtime.provider() {
                    return Err(RunError::ResolvedModelProviderMismatch {
                        model: model.id.clone(),
                        model_provider: model.provider.as_str().to_string(),
                        runtime_provider: runtime.provider().to_string(),
                    });
                }
                model
            }
        };

        // Skills must be registered on the runtime before any session spawns,
        // so every agent's tool roster includes `load_skill`.
        let (skills_dirs, skills) = if self.discovery_enabled {
            let dirs =
                register_skills(runtime.mentra_runtime_internal(), &self.path, &self.skills)?;
            let loaded = runtime
                .mentra_runtime_internal()
                .skills()
                .into_iter()
                .map(|skill| LoadedSkill {
                    name: skill.name,
                    description: skill.description,
                    model_invocable: skill.model_invocable,
                    path: skill.path,
                })
                .collect();
            (dirs, loaded)
        } else {
            (Vec::new(), Vec::new())
        };

        // Beside the skills and for the same reason: a tool has to be on the
        // runtime before any session spawns, or the first roster is offered
        // without it. The names are claimed first, so a manifest naming a tool
        // this runtime already answers to — `spawn`, a mentra builtin, another
        // workspace's declaration — refuses the open instead of replacing it.
        let declared_tools = DeclaredTools::register(
            Arc::clone(&runtime),
            &dispatch::canonical(&self.path),
            &declared_sources,
        )?;
        let declared_tool_names = declared_tools.names().to_vec();

        // Templates need no runtime registration — they are basis-side convention
        // data, rendered into a prompt by whatever surface offers them.
        let (templates_dirs, templates) = if self.discovery_enabled {
            load_templates(&self.path, &self.templates)?
        } else {
            (Vec::new(), Vec::new())
        };

        // One runner for both interception bindings, host interceptors folded
        // first: the chain order host interceptors → global hooks → workspace
        // hooks predates the runtime split and survives it — only the
        // registration point moved, onto the runtime's dispatcher.
        let runner = runtime.interceptors().iter().cloned().fold(
            HookRunner::new(&self.path, loaded_hooks),
            |runner, interceptor| runner.with_interceptor(interceptor),
        );
        // Written by every mint, read by `spawn` when a child policy narrows a
        // delegated child's roster — see `Workspace::minted_agent`. Empty
        // until the first mint, which is correct: nothing has been offered a
        // roster yet, so nothing has been denied one either.
        let foreign_tools = Arc::new(std::sync::RwLock::new(std::collections::BTreeSet::new()));
        let hook_registration = runtime.register_workspace(dispatch::WorkspaceGuardEntry {
            runner: Arc::new(runner),
            shell: self.shell,
            root: dispatch::canonical(&self.path),
            // On a private runtime the shell posture and the `.git` carve-out
            // are already in policy; enforcing them in the dispatcher too
            // would change whose words a denial arrives in.
            shared,
            foreign_tools: Arc::clone(&foreign_tools),
        });

        // Both lists reach the header whether or not this build has MCP in it:
        // what a run reports is a schema clients parse, and a field that
        // vanished with a cargo feature would make the stream's shape depend on
        // how basis was built.
        #[cfg(feature = "mcp")]
        let (mcp_connections, mcp_files, mcp_servers) = {
            if self.discovery_enabled {
                let (files, servers) = discovered_mcp(&self.path, &self.mcp)?;
                let connections =
                    McpConnections::connect(Arc::clone(&runtime), &self.path, servers).await;
                let names = connections.names().to_vec();

                (connections, files, names)
            } else {
                (
                    McpConnections::empty(Arc::clone(&runtime), &self.path),
                    Vec::new(),
                    Vec::new(),
                )
            }
        };
        #[cfg(not(feature = "mcp"))]
        let (mcp_files, mcp_servers): (Vec<ContextFile>, Vec<String>) = (Vec::new(), Vec::new());

        let reuse = reusable_recipe.map(|recipe| WorkspaceReuse::new(recipe, self.shell));

        Ok(Workspace {
            root: resolved_workspace(&self.path, &context),
            // Compaction is two statements from two owners, joined here: the
            // numbers are this workspace's, the directory the snapshots land in
            // is the runtime's, because it is the one that knows where this
            // workspace's history lives (ADR-0018).
            agent: agent_config(
                &self.path,
                &context,
                self.system_prompt.as_ref(),
                memory::index_block(&memories).as_deref(),
                self.roster,
                self.compaction,
                runtime.transcripts_dir().to_path_buf(),
            ),
            identifier: store::runtime_identifier(&self.path),
            path: self.path,
            provider: runtime.provider().to_string(),
            runtime,
            reuse,
            mint_posture: MintPosture::new(fresh_only),
            model,
            // The last thing the file still has to say, and the one this
            // builder cannot say for it: an effort is a per-turn request, so
            // it waits here until a `RunSpec` that asked for none is minted.
            effort: config.effort.as_ref().map(|effort| effort.value),
            config,
            context,
            memories,
            skills_dirs,
            skills,
            templates_dirs,
            templates,
            mcp_files,
            mcp_servers,
            declared_tool_files: sourced(&declared_sources),
            declared_tools: declared_tool_names,
            declared_registration: declared_tools,
            hook_registration,
            foreign_tools,
            #[cfg(feature = "mcp")]
            mcp_connections,
        })
    }
}

/// Which tool manifests took effect, for the workspace's own report.
///
/// The same shape `.mcp.json`'s discovery reports, because the two files raise
/// the same question: a caller looking at a run should be able to see which
/// file put a program within the model's reach.
fn sourced(sources: &[declared::ToolsSource]) -> Vec<ContextFile> {
    sources
        .iter()
        .map(|source| ContextFile {
            path: source.path.clone(),
            scope: source.scope.label(),
        })
        .collect()
}

/// Discovers the MCP servers this workspace connects, and which files said so.
///
/// Discovery runs for its own sake as well: the header names which files took
/// effect, and an `.mcp.json` is the last thing that should apply invisibly —
/// it says which programs to spawn. The connecting happens in
/// [`crate::mcp::connections`], which owns the claim-and-bridge fold.
#[cfg(feature = "mcp")]
fn discovered_mcp(
    workspace: &Path,
    config: &McpConfig,
) -> Result<(Vec<ContextFile>, Vec<mcp::ConfiguredServer>), RunError> {
    let files: Vec<ContextFile> = mcp::discover(workspace, config)?
        .iter()
        .map(|source| ContextFile {
            path: source.path.clone(),
            scope: source.scope.label(),
        })
        .collect();

    Ok((files, mcp::configured(workspace, config)?))
}

/// Registers every skills directory that exists, most specific first.
///
/// Roots layer rather than replace, so a workspace skill shadows a personal one
/// of the same name and everything else from the weaker roots still loads. Which
/// four roots those are, and why they are in that order, is [`crate::skills`].
fn register_skills(
    runtime: &mentra::Runtime,
    workspace: &Path,
    config: &SkillsConfig,
) -> Result<Vec<PathBuf>, RunError> {
    let sources = skills::discover(workspace, config);
    let paths: Vec<PathBuf> = sources.iter().map(|source| source.path.clone()).collect();

    runtime.register_skills_dirs(&paths)?;

    Ok(paths)
}

/// Loads every template the workspace defines, with the roots they came from.
///
/// A root that exists but holds a file basis cannot read is an error rather than
/// an empty command list: a template that failed to load and a template nobody
/// wrote look the same from a client, and only one of them is worth knowing
/// about.
///
/// Shared with [`prepare_with_session`](crate::run::prepare_with_session), which
/// discovers templates for a runtime it does not own — one implementation, so
/// the two cannot disagree about which files are a workspace's commands.
pub(crate) fn load_templates(
    workspace: &Path,
    config: &TemplatesConfig,
) -> Result<(Vec<PathBuf>, Vec<Template>), RunError> {
    let sources = templates::discover(workspace, config);
    let dirs: Vec<PathBuf> = sources.iter().map(|source| source.path.clone()).collect();

    Ok((dirs, templates::load_sources(&sources)?))
}

/// The workspace as discovery resolved it, falling back to what was asked for.
///
/// Discovery follows symlinks so the parent walk is meaningful, which means a
/// document's path can sit under a different spelling of the same directory
/// than the caller typed. Reporting the resolved root keeps the header
/// internally consistent — `workspace` and `context_files` name one place.
///
/// Shared with [`prepare_with_session`](crate::run::prepare_with_session) for
/// the same reason [`load_templates`] is: the one path that does not open a
/// workspace must still report one the same way.
pub(crate) fn resolved_workspace(requested: &Path, context: &WorkspaceContext) -> PathBuf {
    context
        .root()
        .map(Path::to_path_buf)
        .unwrap_or_else(|| requested.to_path_buf())
}

/// Turns discovered context into the agent's system prompt, scopes the agent to
/// the workspace, settles which tools the model is offered, and says how much
/// of the conversation reaches the model. Everything else stays at mentra's
/// defaults — opinions belong in the prompt and the workspace, not here.
///
/// `system_prompt` is the host's say over the first of those, and `None` — the
/// default and what every caller before it did — is discovery alone. basis
/// still ships no prompt of its own: the text in either variant is the host's.
///
/// # Why compaction is not left at mentra's default
///
/// Because evidence retention is a Basis invariant, not an upstream default.
/// Mentra currently also keeps every result, but Basis pins that posture so a
/// future default cannot silently blank what the model just read. See
/// [`crate::compaction`]. The mutually exclusive projected-byte policy is
/// explicitly off; the remaining unexposed settings are inherited.
///
/// # Which tools the model is offered
///
/// `roster` is [`ToolRoster`] (decision D3), a workspace's own knob over
/// mentra's `ToolProfile` — see its module docs for what each constructor
/// does and does not change, and for the two things (a sibling workspace's
/// hidden tools, the rendered prompt) that apply on top of whatever roster is
/// set here regardless.
///
/// **Hidden is a roster fact, not a capability fact.** Every tool a roster
/// hides stays registered on the runtime, which is precisely why `spawn` can
/// still reach the command executor underneath even though
/// [`ToolRoster::default`] hides it by name. What a caller said about
/// commands is still decided by [`ShellAccess`] — baked into policy on a
/// private runtime, enforced by the hook dispatcher on a shared one — on the
/// path `spawn` uses: `--no-shell` shuts commands off for `spawn` exactly as
/// it did for `shell`.
///
/// The roster travels: `DisposableSubagentTemplate::from_agent` clones this
/// whole config, so a subagent of a subagent is offered the same roster.
///
/// Built once and cloned per run, because none of its inputs are per-run —
/// the per-mint extension (hiding other workspaces' MCP tools) happens in
/// [`Workspace::prepare`](super::Workspace::prepare)'s path, where the shared
/// registry's current contents are known.
fn agent_config(
    workspace: &Path,
    context: &WorkspaceContext,
    system_prompt: Option<&SystemPrompt>,
    memory_index: Option<&str>,
    roster: ToolRoster,
    compaction: Compaction,
    transcripts: PathBuf,
) -> mentra::agent::AgentConfig {
    mentra::agent::AgentConfig {
        // The memory index rides the context's own render path — after the
        // documents, before a host's `Append`, gone under `Replace` — so it
        // obeys the same rules as everything else in the prompt, and none of
        // them consult `roster` at all (item d of D3).
        system: context.render_with_appendix(system_prompt, memory_index),
        tool_profile: roster.into_profile(),
        workspace: mentra::agent::WorkspaceConfig {
            base_dir: workspace.to_path_buf(),
            ..Default::default()
        },
        compaction: compaction.into_mentra(transcripts),
        // D2 (wave 1): mentra's memory engine is off. basis's memory is a
        // file convention (`crate::memory`), and mentra's is a store —
        // auto-recall would put that store's content into the prompt with
        // nothing visible saying so, which is exactly the kind of silent
        // input basis exists to remove. Recall off here, the three memory
        // tools hidden in `ToolRoster`'s default set, and the write tools
        // refused at execution too, so no unhidden path can reach the store
        // either.
        memory: mentra::agent::MemoryConfig {
            auto_recall_enabled: false,
            write_tools_enabled: false,
            ..Default::default()
        },
        ..Default::default()
    }
}

#[cfg(test)]
mod tests;