basis 0.12.2

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
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
//! 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, SessionScope},
    shell::ShellAccess,
    skills::{self, SkillRoots, SkillsConfig},
    store,
    templates::{self, Template, TemplatesConfig},
    tools::{
        declared::{self, DeclaredTools, ToolsConfig},
        host::WorkspaceHostTools,
    },
};

use super::{Workspace, 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,
    /// Native tools the host supplied for *this* workspace, registered for its
    /// audience at [`open`](WorkspaceBuilder::open).
    host_tools: Vec<Box<dyn crate::tools::ExecutableTool>>,
    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>),
}

/// 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,
                },
            )
            .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("host_tools", &self.host_tools.len())
            .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(),
            host_tools: Vec::new(),
            // 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
        }
    }

    /// 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: Basis has no scrub contract for a partly minted or
    /// resumed runtime, so it cannot prove one 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
        }
    }

    /// Every discovered input rewritten to probe nothing, with what the host
    /// stated left exactly as it was.
    ///
    /// [`without_discovery`](Self::without_discovery)'s whole effect, in one
    /// place. Each convention answers *probe no files* in its own words, and
    /// the three with a host-supplied half keep it — supplying a hook, a
    /// declared tool or an MCP server is stating one, not discovering one, and
    /// D9 turns off discovery. An unset [`Config`] becomes the empty one for
    /// the same reason: `Config::discover` is the probe, and a host that
    /// supplied a config through [`with_config`](Self::with_config) already
    /// said what it wanted.
    ///
    /// Applied at the top of [`open`](Self::open) rather than from
    /// `without_discovery` itself, so builder-setter order stays irrelevant.
    fn probing_no_files(self) -> Self {
        Self {
            context: ContextConfig::none(),
            config: Some(self.config.unwrap_or_default()),
            skills: SkillsConfig::none(),
            memory: MemoryConfig::disabled(),
            templates: TemplatesConfig::none(),
            hooks: self.hooks.supplied_only(),
            tools: self.tools.supplied_only(),
            #[cfg(feature = "mcp")]
            mcp: self.mcp.supplied_only(),
            ..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 a roster says nothing about — a sibling
    /// workspace's tools, which its own audience keeps out of reach, 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 the host-supplied subprocess hooks and where file 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.
    /// Typed [`HooksConfig::supplied`](crate::hooks::HooksConfig::supplied)
    /// hooks run before global and workspace file hooks; disabling discovery
    /// retains only that typed list.
    pub fn with_hooks(self, hooks: HooksConfig) -> Self {
        Self { hooks, ..self }
    }

    /// Sets the host-supplied declared tools and where file declarations 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. Typed
    /// [`ToolsConfig::supplied`](crate::tools::declared::ToolsConfig::supplied)
    /// entries outrank workspace and global files and remain active when file
    /// discovery is disabled.
    pub fn with_tools(self, tools: ToolsConfig) -> Self {
        Self { tools, ..self }
    }

    /// Registers a tool the *host* implements, in the embedding program's own
    /// process, for this workspace alone.
    ///
    /// The per-workspace half of what
    /// [`RuntimeBuilder::with_tool`](crate::RuntimeBuilder::with_tool) does
    /// process-wide, and the same `ExecutableTool` contract — `crate::tools`
    /// has what a host writes one against, and why a native tool exists at all
    /// beside a [`declared`](crate::tools::declared) one.
    ///
    /// **What "for this workspace alone" means.** The tool is registered for
    /// this workspace's [`ToolAudience`](mentra::tool::ToolAudience), so on a
    /// runtime serving five repositories the other four's models are neither
    /// offered it nor able to reach it by guessing the name — mentra's
    /// resolution ladder answers a foreign audience's name with `Hidden`. A
    /// run this workspace mints sees it, and so does a subagent that run
    /// delegates to, which inherits the audience with the runtime handle it is
    /// spawned from. Nothing is frozen to achieve that: mentra rebuilds a
    /// visible set from the live registry each round, so a workspace that
    /// opens later adds tools only to its own audience.
    ///
    /// **A name that is taken refuses the open**, naming it
    /// ([`RunError::WorkspaceHostToolNameTaken`](crate::RunError::WorkspaceHostToolNameTaken)) —
    /// a global this runtime already answers to, another repository's, or one
    /// another live open of *this* directory supplied. That last is the one
    /// case a declaration handles differently, and the error variant carries
    /// the argument: two opens of one directory share one audience, and two
    /// `dyn ExecutableTool` values cannot be compared, so the second is
    /// refused rather than silently served the first's closure. Nothing is
    /// registered when any name in the set is refused.
    ///
    /// **A host tool's `Drop` must not block.** Its registration is released
    /// while basis holds the lock over the runtime's tool-name ledger, so a
    /// handler that waits on a lock, a channel or a network round trip on its
    /// way out stalls every other workspace opening or closing on that
    /// runtime. mentra drops its own handlers outside its registry lock and
    /// basis cannot: the claim and the registration have to go together or a
    /// name is briefly free with a tool still answering to it. Detached work
    /// owned only by the tool is outside what a workspace's lifetime covers.
    ///
    /// Call it once per tool; order is the order they are claimed in.
    pub fn with_tool<T>(self, tool: T) -> Self
    where
        T: crate::tools::ExecutableTool + 'static,
    {
        Self {
            host_tools: {
                let mut host_tools = self.host_tools;
                host_tools.push(Box::new(tool));
                host_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,
    /// and carried as such: it goes into the policy every session this
    /// workspace mints ([`crate::Runtime`]), so a shared runtime holds this
    /// posture for these runs and a sibling repository's for its own. A
    /// private runtime bakes it as well, for anything reached through
    /// [`Runtime::mentra_runtime`](crate::Runtime::mentra_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.
    ///
    /// # Where the workspace is
    ///
    /// The path is made absolute and canonical here, once, and that resolved
    /// directory is [`Workspace::root`] — a workspace opened as `.`, through a
    /// symlink, or with a `..` in it reports the directory those spellings
    /// name, not the spelling. Everything downstream takes that one value: the
    /// agent's base directory, the runtime's policy roots, the hook runner's
    /// directory, the store identifier — which also names this workspace's tool
    /// audience — and the run header's
    /// `workspace`. Nothing resolves it again, so a process that changes its
    /// working directory afterwards changes nothing about a workspace already
    /// open. A path that does not exist, or is not a directory, fails the open
    /// here rather than at the first tool call.
    ///
    /// # What this workspace's conversations are tagged with
    ///
    /// Every agent minted from here carries
    /// [`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*. [`Runtime::mint`](crate::Runtime) states it
    /// per session, so a shared runtime tags each workspace's conversations
    /// with that workspace rather than with the process.
    ///
    /// A *resumed* conversation used to be an exception, and it was
    /// upstream's: mentra's resume options carried no identifier, so a
    /// resumed session re-filed under the runtime's own tag when it next
    /// persisted — which on a shared runtime took it out of this list.
    /// mentra 0.27 closed that (mentra#54) by retaining a resumed agent's own
    /// stored tag instead. [`crate::store`] has the whole of it, including
    /// the older-record gap that fix opened in its place.
    ///
    /// # 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 for as long as both
    /// are open — an accepted consequence of sharing, and what
    /// [`Workspace::skills`] therefore reports. It ends with the workspace: the
    /// roots this open registered come off the runtime when the [`Workspace`]
    /// drops, so a sibling that outlives it stops being able to reach its
    /// skills, and a root two workspaces both registered — the user's global
    /// ones, on any host that opens more than one repository — stays until the
    /// last of them goes. MCP tools live on the same single registry but do
    /// **not** travel even while both are open: every roster minted here hides
    /// the `mcp__*` tools of servers this workspace does not own, and every
    /// call of one is refused by this workspace's own interception chain
    /// whether or not the roster it was offered had caught it
    /// (`runtime::agents`, crate-private).
    pub async fn open(mut self) -> Result<Workspace, RunError> {
        // 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 before anything else, so the refusal precedes workspace
        // validation, 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;

        // **`without_discovery` is answered once, here.** Rewriting the
        // configs it governs rather than branching at each of the eight places
        // one is settled below is what keeps the open a straight line, and
        // what makes the answer impossible to give differently in two of them.
        // Done after the builder chain rather than inside the setter, so
        // setter order stays irrelevant: a host may call `without_discovery`
        // before or after `with_mcp` and mean the same thing either way.
        if !self.discovery_enabled {
            self = self.probing_no_files();
        }

        // **The one resolution.** Everything below this line names the
        // workspace through `path` and nothing re-derives it: the private
        // runtime's policy roots, the agent's base directory,
        // the hook runner's directory, the store identifier and the run
        // header all take this value. A relative spelling would otherwise
        // survive into all of them and be resolved again — against whatever
        // the process's working directory happened to be at the time, which is
        // not a thing a workspace should depend on: mentra normalizes a policy
        // root at every check, not at construction
        // (`RuntimePolicy::normalize_policy_root`), so a relative root means
        // *the same* run answers differently after a `chdir`. Canonical, not
        // merely absolute, for the reason
        // [`store::runtime_identifier`](crate::store::runtime_identifier)
        // gives: a symlinked spelling and its target are one workspace.
        let path = crate::context::resolve_workspace(&self.path)?;

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

        // 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 => config::Config::discover(&path, self.context.global_dir.as_deref())?,
        };

        // 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 = hooks::load(&path, &self.hooks)?;

        // Validate supplied values and read files here for the same reason, and
        // one of their own: an invalid declaration is a tool the model's
        // instructions assume and will not find. Registering needs the runtime,
        // so that waits until there is one.
        let supplied_tools = declared::load_supplied(&self.tools)?;
        let declared_sources = declared::discover(&path, &self.tools)?;

        // 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. `None` here is
        // what makes `memory::roots` skip the workspace root entirely on a
        // shared runtime. 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),
        };
        // 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) = 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)??;
        let memory_roots: Vec<PathBuf> = memory_sources
            .iter()
            .map(|source| source.path.clone())
            .collect();

        let runtime = 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,
            RuntimeSource::Private(recipe) => Arc::new(recipe.with_config(&config).build_for(
                &path,
                self.shell,
                &memory_roots,
            )?),
        };

        // The live scope every session minted here runs in. Derived once, from
        // the same recipe the private path just baked into the runtime, so the
        // two runtime shapes differ in nothing a run can observe: a shared
        // runtime's session carries this workspace's shell posture, `.git`
        // carve-out and memory roots because it is handed them, and a private
        // runtime's carries them twice over. The audience below is derived from
        // the same identity, and is what this workspace's own tools are
        // registered under a few lines further down.
        let scope = SessionScope {
            identifier: store::runtime_identifier(&path),
            policy: runtime.session_policy(&path, self.shell, &memory_roots),
        };
        let audience = scope.audience();

        // 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`. The hold is a
        // stack value until the `Workspace` below takes it: every `?` between
        // here and there drops it, so an open refused after this point leaves
        // a shared runtime holding no skills of a workspace that never opened.
        let skills_registration = register_skills(Arc::clone(&runtime), &path, &self.skills)?;
        let skills: Vec<LoadedSkill> = runtime
            .mentra_runtime()
            .skills()
            .into_iter()
            .map(|skill| LoadedSkill {
                name: skill.name,
                description: skill.description,
                model_invocable: skill.model_invocable,
                path: skill.path,
                root: skill.root,
            })
            .collect();

        // 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.
        // The root is `path` as resolved above and is not canonicalized again:
        // that would be the second resolution this open exists to do without.
        let declared_tools = DeclaredTools::register_with_supplied(
            Arc::clone(&runtime),
            &audience,
            &path,
            &declared_sources,
            &supplied_tools,
        )?;
        let declared_tool_names = declared_tools.names().to_vec();

        // Beside the declared tools, claimed on the same ledger and registered
        // for the same audience — the difference is only whose statement the
        // tool is. A declaration came out of the repository; these came from
        // the host, for this workspace and no other one this runtime carries.
        //
        // **After the declarations, and one refusal's wording depends on it.**
        // A repository's own manifest keeps first call on a name it already
        // uses, which is reason enough — but `Runtime::claim_declared_tool`
        // also tells a declaration that a native tool under its name belongs
        // to *another* live open, and that is only true because this open has
        // made no native claim of its own by the time the declarations run.
        // Registering host tools first would make that message quietly wrong,
        // with nothing to catch it.
        let host_tools = WorkspaceHostTools::register(
            Arc::clone(&runtime),
            &audience,
            &path,
            std::mem::take(&mut self.host_tools),
        )?;
        let host_tool_names = host_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) = load_templates(&path, &self.templates)?;

        // This workspace's own hooks, and only its own: the host's
        // interceptors are the *runtime's* and were registered globally when it
        // was built (`runtime::interception`). The documented order — host
        // interceptors → supplied hooks → global hooks → workspace hooks — is
        // unchanged and is now mentra's to compose: it walks one chain per call
        // built from every batch whose audience matches, in registration order,
        // and this runtime's global batch necessarily precedes any workspace
        // batch registered on it.
        //
        // Registered for this workspace's audience, not globally, because a
        // runner does not filter: it answers for every call it is handed. The
        // audience is what makes "this workspace's hooks judge this
        // workspace's runs" true, and it is a better answer than the working
        // directory basis used to route on — two agents can share a directory
        // and belong to different repositories, and a call from a delegated
        // child carries its parent's audience wherever it runs. What it cannot
        // express is the reverse: a session with *no* audience whose base
        // directory is inside this workspace. See `Workspace`'s own docs.
        let runner = HookRunner::new(&path, loaded_hooks);
        // basis's own guard, ahead of whatever this repository declared,
        // because a tool this workspace does not own is not a call a
        // repository's hook should have to be written to catch. It reads the
        // runtime's agent ledger rather than this workspace, which is what
        // makes it right for the *other* live open of this directory too: that
        // open joins this chain rather than registering one of its own, so only
        // one of the two guards is ever live and it has to answer for both.
        // See `crate::runtime::agents::ForeignToolGuard`.
        let runner = runner.with_interceptor(crate::runtime::agents::ForeignToolGuard::new(
            Arc::clone(runtime.agents()),
            runtime.tool_claims(),
        ));
        // One registration, both seams: mentra 0.26 takes a chain as
        // `ExecutionHookParticipant`s and answers with a single guard whose
        // participant snapshot is retained across a whole call. Two guards, one
        // per seam, could not promise that — a drop between the tool and its
        // result would leave the call half-guarded — and only the mixed chain
        // carries a rewrite's attribution into the refusal a rejected rewrite
        // earns. Taken unconditionally: whether this workspace will ever answer
        // about a *result* is knowable here, but a runner with nothing to say
        // costs one map walk and the alternative is a workspace that silently
        // could not be given a post hook.
        //
        // Through the runtime's ledger rather than straight at mentra, because
        // one root may be open twice and one audience must carry one chain:
        // an identical second open joins, a differing one is refused. The `?`
        // is the refusal, and it comes after the registrations above only
        // because those release themselves on the way out.
        let hooks = runtime.register_hook_chain(&audience, &path, runner)?;
        // 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) = {
            let (files, servers) = discovered_mcp(&path, &self.mcp)?;
            let connections =
                McpConnections::connect(Arc::clone(&runtime), &audience, &path, servers).await;
            let names = connections.names().to_vec();

            (connections, files, names)
        };
        #[cfg(not(feature = "mcp"))]
        let (mcp_files, mcp_servers): (Vec<ContextFile>, Vec<String>) = (Vec::new(), Vec::new());

        Ok(Workspace {
            // 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(
                &path,
                &context,
                self.system_prompt.as_ref(),
                memory::index_block(&memories).as_deref(),
                self.roster,
                self.compaction,
                runtime.transcripts_dir().to_path_buf(),
            ),
            // Not resolved a second time: `path` *is* what discovery resolved,
            // and asking again would reintroduce the second answer this open
            // exists to do without.
            root: path,
            provider: runtime.provider().to_string(),
            runtime,
            scope,
            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_registration,
            skills,
            templates_dirs,
            templates,
            mcp_files,
            mcp_servers,
            declared_tool_files: sourced(&declared_sources),
            declared_tools: declared_tool_names,
            declared_registration: declared_tools,
            host_tools: host_tool_names,
            host_tool_registration: host_tools,
            hooks,
            #[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, and
/// returns the hold that gives them back.
///
/// 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`];
/// what the returned value is for, and why the runtime counts holders rather
/// than owners, is [`SkillRoots`].
fn register_skills(
    runtime: Arc<Runtime>,
    workspace: &Path,
    config: &SkillsConfig,
) -> Result<SkillRoots, RunError> {
    let sources = skills::discover(workspace, config);
    let paths: Vec<PathBuf> = sources.iter().map(|source| source.path.clone()).collect();

    SkillRoots::register(runtime, 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)?))
}

/// 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`], in the policy every session
/// carries, 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.
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;