basis 0.2.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
//! 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::ModelSelector;

#[cfg(feature = "mcp")]
use crate::mcp::{self, McpConfig, connections::McpConnections};
use crate::{
    context::{ContextConfig, WorkspaceContext},
    event::ContextFile,
    hooks::{self, HookRunner, HooksConfig},
    run::{LoadedSkill, RunError},
    runtime::{Runtime, RuntimeBuilder, dispatch},
    shell::ShellAccess,
    skills::{self, SkillsConfig},
    store,
    templates::{self, Template, TemplatesConfig},
};

use super::Workspace;

/// 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, unlike [`RunConfig`](crate::RunConfig)'s, 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,
    /// An override; `None` defers to the runtime's model policy.
    model: Option<ModelSelector>,
    context: ContextConfig,
    skills: SkillsConfig,
    #[cfg(feature = "mcp")]
    mcp: McpConfig,
    templates: TemplatesConfig,
    hooks: HooksConfig,
    shell: ShellAccess,
}

/// Where this workspace's runtime comes from: borrowed from the host, or
/// built privately from a recipe, bound to this workspace's path.
enum RuntimeSource {
    Shared(Arc<Runtime>),
    Private(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("model", &self.model)
            .field("context", &self.context)
            .field("skills", &self.skills)
            .field("templates", &self.templates)
            .field("hooks", &self.hooks)
            .field("shell", &self.shell)
            .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(RuntimeBuilder::default()),
            model: None,
            context: ContextConfig::default(),
            skills: SkillsConfig::default(),
            #[cfg(feature = "mcp")]
            mcp: McpConfig::default(),
            templates: TemplatesConfig::default(),
            hooks: HooksConfig::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(),
        }
    }

    /// 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(runtime),
            ..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: Some(model),
            ..self
        }
    }

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

    pub fn with_skills(self, skills: SkillsConfig) -> Self {
        Self { skills, ..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 }
    }

    /// 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 }
    }

    /// 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> {
        let context = WorkspaceContext::discover_with(&self.path, &self.context)?;

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

        let shared = matches!(self.runtime, RuntimeSource::Shared(_));
        let runtime = match self.runtime {
            RuntimeSource::Shared(runtime) => runtime,
            RuntimeSource::Private(recipe) => Arc::new(recipe.build_for(&self.path, self.shell)?),
        };

        let model = runtime.resolve_model(self.model).await?;

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

        // 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(&self.path, &self.templates)?;

        // 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),
        );
        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,
        });

        // 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(&self.path, &self.mcp)?;
            let connections =
                McpConnections::connect(Arc::clone(&runtime), &self.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 {
            root: resolved_workspace(&self.path, &context),
            agent: agent_config(&self.path, &context),
            identifier: store::runtime_identifier(&self.path),
            path: self.path,
            provider: runtime.provider().to_string(),
            runtime,
            model,
            context,
            skills_dirs,
            skills,
            templates_dirs,
            templates,
            mcp_files,
            mcp_servers,
            hook_registration,
            #[cfg(feature = "mcp")]
            mcp_connections,
        })
    }
}

/// 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::McpServer>), 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::servers(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 global root still loads.
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, and settles which tools the model is offered. Everything else
/// stays at mentra's defaults — opinions belong in the prompt and the
/// workspace, not here.
///
/// # Why three tools leave the roster
///
/// ADR-0016 gives the model one door for *do something I cannot do by
/// thinking*: [`spawn`](crate::tools::spawn). `shell` and `background_run` and
/// `task` are the doors it replaces, and leaving them alongside it would
/// restore exactly what the ADR removed — three names at the approval gate, and
/// three rule namespaces, for one question.
///
/// **Hidden is a roster fact, not a capability fact.** All three stay
/// registered on the runtime, which is precisely why `spawn` can still reach
/// the command executor underneath. 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 hidden set travels: `DisposableSubagentTemplate::from_agent` clones this
/// whole config, so a subagent of a subagent is offered the same one door.
///
/// 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) -> mentra::agent::AgentConfig {
    mentra::agent::AgentConfig {
        system: context.render(),
        tool_profile: mentra::agent::ToolProfile::hide(REPLACED_TOOLS),
        workspace: mentra::agent::WorkspaceConfig {
            base_dir: workspace.to_path_buf(),
            ..Default::default()
        },
        ..Default::default()
    }
}

/// The tools `spawn` replaces, by the names mentra registers them under.
const REPLACED_TOOLS: [&str; 3] = ["shell", "background_run", "task"];

#[cfg(test)]
mod tests;