trusty-mpm 1.0.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
//! Precedence resolution for the harness manifest (HR-2 / DOC-17).
//!
//! Why: HR-2 mandates a single NORMATIVE precedence —
//! **project override > user config > catalog manifest > compiled-in default** —
//! and `prepare_session` must consume the *resolved* manifest. Centralizing the
//! layer reads and the merge order here keeps that precedence in one auditable
//! place and makes it testable without touching the real `~/.trusty-mpm`.
//! What: [`resolve_manifest`] starts from the compiled-in default
//! ([`super::default::default_manifest`]) and overlays, in ascending precedence,
//! the catalog manifest, the user-config manifest, then the project-override
//! manifest — each via [`HarnessManifest::merge`]. A missing or malformed layer
//! is logged and skipped (never blocks a launch, per the HR-2 error contract).
//! The canonical layer locations are resolved by [`ManifestSources::resolve`]
//! from a project dir + framework root + catalog root.
//! Test: `resolve_uses_default_when_no_layers`, `resolve_project_wins`,
//! `resolve_user_over_catalog`, `resolve_malformed_layer_is_skipped`.

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

use super::default::default_manifest;
use super::schema::{AgentSet, HarnessManifest};

/// File name of a harness manifest in every layer location.
///
/// Why: the project override, user config, and catalog all use the same file
/// name; naming it once prevents drift.
/// What: `manifest.toml`.
/// Test: `manifest_sources_resolve_canonical_paths`.
pub const MANIFEST_FILE: &str = "manifest.toml";

/// The concrete file paths of the three override layers.
///
/// Why: `prepare_session` (and tests) need to point the resolver at specific
/// files; bundling the three optional paths keeps the resolver signature small
/// and lets tests construct an arbitrary layer set.
/// What: the project-override path (highest precedence), the user-config path,
/// and the catalog path (lowest of the three; the compiled-in default sits below
/// all of them). A `None` field means "that layer is absent".
/// Test: `manifest_sources_resolve_canonical_paths`.
#[derive(Debug, Clone, Default)]
pub struct ManifestSources {
    /// `<project>/.trusty-mpm/manifest.toml` — highest precedence.
    pub project: Option<PathBuf>,
    /// `~/.trusty-mpm/manifest.toml` — user config.
    pub user: Option<PathBuf>,
    /// `~/.trusty-mpm/catalog/repo/.claude/manifest.toml` — synced catalog.
    pub catalog: Option<PathBuf>,
    /// Auto-detected per-project language agent scoping (#1941).
    ///
    /// Why: a Rust-only workspace should not receive the full polyglot roster.
    /// [`ManifestSources::resolve`] fills this from
    /// [`super::project_lang::language_agent_scope`]; [`resolve_manifest`] applies
    /// it as the lowest override layer (above the compiled default, below the
    /// catalog/user/project manifests) so an explicit `[agents]` override always
    /// wins. `None` means "no recognized language" → deploy everything (unchanged).
    pub language_scope: Option<AgentSet>,
}

impl ManifestSources {
    /// Resolve the canonical layer paths from a project dir and the framework
    /// and catalog roots.
    ///
    /// Why: the layer locations are conventions (DOC-17 §HR-2); resolving them in
    /// one place keeps `prepare_session` from hard-coding the joins and lets the
    /// catalog source stay configurable (the caller passes whatever
    /// `CatalogSync` used as its catalog root).
    /// What: builds `<project>/.trusty-mpm/manifest.toml`,
    /// `<framework_root>/manifest.toml`, and
    /// `<catalog_root>/repo/.claude/manifest.toml`. Each path is recorded
    /// unconditionally; the resolver tolerates absent files.
    ///
    /// Note: the USER manifest is `<framework_root>/manifest.toml` — a sibling of
    /// `<framework_root>/config.toml` (i.e. `~/.trusty-mpm/manifest.toml` in
    /// production). This co-location with `config.toml` is intentional: both are
    /// user-level, framework-rooted files, so an operator finds them in one place.
    /// Test: `manifest_sources_resolve_canonical_paths`.
    pub fn resolve(project_dir: &Path, framework_root: &Path, catalog_root: &Path) -> Self {
        Self {
            project: Some(project_dir.join(".trusty-mpm").join(MANIFEST_FILE)),
            user: Some(framework_root.join(MANIFEST_FILE)),
            catalog: Some(
                catalog_root
                    .join("repo")
                    .join(".claude")
                    .join(MANIFEST_FILE),
            ),
            language_scope: super::project_lang::language_agent_scope(project_dir),
        }
    }
}

/// Resolve the effective harness manifest by applying the NORMATIVE precedence.
///
/// Why: this is the single seam that implements HR-2's precedence; every consumer
/// (today `prepare_session`, tomorrow HR-3's staleness check) resolves the
/// manifest through here so the layer order can never drift.
/// What: starts from [`default_manifest`] (the floor) and overlays, lowest-to-
/// highest precedence, the catalog layer, the user layer, then the project layer.
/// Each layer is read via [`read_layer`]; a missing file contributes nothing and
/// a malformed file is logged and skipped (the launch never blocks — HR-2 error
/// contract). The returned manifest always has every section populated because
/// the default floor fills any section no layer overrode.
/// Test: `resolve_uses_default_when_no_layers`, `resolve_project_wins`,
/// `resolve_user_over_catalog`, `resolve_malformed_layer_is_skipped`.
pub fn resolve_manifest(sources: &ManifestSources) -> HarnessManifest {
    let mut manifest = default_manifest();

    // Per-project language scoping (#1941): the lowest override layer, applied on
    // top of the compiled default but below the catalog/user/project manifests so
    // an explicit `[agents]` override always wins. Absent (unknown project type)
    // → no scoping, deploy everything (zero-regression).
    if let Some(scope) = &sources.language_scope {
        manifest = manifest.merge(HarnessManifest {
            agents: Some(scope.clone()),
            ..HarnessManifest::default()
        });
    }

    // Lowest-to-highest precedence: catalog → user → project.
    if let Some(path) = &sources.catalog
        && let Some(layer) = read_layer(path, "catalog")
    {
        manifest = manifest.merge(layer);
    }
    if let Some(path) = &sources.user
        && let Some(layer) = read_layer(path, "user")
    {
        manifest = manifest.merge(layer);
    }
    if let Some(path) = &sources.project
        && let Some(layer) = read_layer(path, "project")
    {
        manifest = manifest.merge(layer);
    }

    manifest
}

/// Read and parse one manifest layer file, tolerating absence and corruption.
///
/// Why: HR-2's error contract says an unresolvable/unreadable layer must never
/// block a launch — a missing file is the common case (no override) and a
/// malformed file must degrade to "skip this layer", not abort.
/// What: returns `Some(manifest)` when the file exists and parses; `None` when it
/// is absent (silent) or malformed (logged at `warn`). `label` names the layer in
/// the warning so operators can find the offending file.
/// Test: `resolve_malformed_layer_is_skipped`, `resolve_project_wins`.
fn read_layer(path: &Path, label: &str) -> Option<HarnessManifest> {
    match std::fs::read_to_string(path) {
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
        Err(err) => {
            tracing::warn!(
                "could not read {label} manifest at {}: {err}; skipping layer",
                path.display()
            );
            None
        }
        Ok(raw) => match HarnessManifest::from_toml(&raw) {
            Ok(m) => {
                tracing::debug!("loaded {label} manifest layer from {}", path.display());
                Some(m)
            }
            Err(err) => {
                tracing::warn!(
                    "{label} manifest at {} is malformed: {err}; skipping layer",
                    path.display()
                );
                None
            }
        },
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
        std::fs::create_dir_all(dir).unwrap();
        let path = dir.join(name);
        std::fs::write(&path, body).unwrap();
        path
    }

    #[test]
    fn manifest_sources_resolve_canonical_paths() {
        let sources = ManifestSources::resolve(
            Path::new("/proj"),
            Path::new("/home/.trusty-mpm"),
            Path::new("/home/.trusty-mpm/catalog"),
        );
        assert_eq!(
            sources.project.unwrap(),
            PathBuf::from("/proj/.trusty-mpm/manifest.toml")
        );
        assert_eq!(
            sources.user.unwrap(),
            PathBuf::from("/home/.trusty-mpm/manifest.toml")
        );
        assert_eq!(
            sources.catalog.unwrap(),
            PathBuf::from("/home/.trusty-mpm/catalog/repo/.claude/manifest.toml")
        );
    }

    #[test]
    fn resolve_uses_default_when_no_layers() {
        // With every layer pointing at a non-existent file, the resolved
        // manifest must equal the compiled-in default (zero-regression).
        let tmp = TempDir::new().unwrap();
        let sources = ManifestSources {
            project: Some(tmp.path().join("p").join("manifest.toml")),
            user: Some(tmp.path().join("u").join("manifest.toml")),
            catalog: Some(tmp.path().join("c").join("manifest.toml")),
            language_scope: None,
        };
        assert_eq!(resolve_manifest(&sources), default_manifest());
    }

    #[test]
    fn resolve_project_wins() {
        // The project layer must override the user and catalog layers for the
        // same section (style here).
        let tmp = TempDir::new().unwrap();
        let catalog = write(
            &tmp.path().join("c"),
            "manifest.toml",
            "[style]\nactive = \"trusty-mpm-research\"\n",
        );
        let user = write(
            &tmp.path().join("u"),
            "manifest.toml",
            "[style]\nactive = \"trusty-mpm-teacher\"\n",
        );
        let project = write(
            &tmp.path().join("p"),
            "manifest.toml",
            "[style]\nactive = \"trusty-mpm\"\n",
        );
        let sources = ManifestSources {
            project: Some(project),
            user: Some(user),
            catalog: Some(catalog),
            language_scope: None,
        };
        let m = resolve_manifest(&sources);
        assert_eq!(
            m.style.and_then(|s| s.active),
            Some("trusty-mpm".to_string()),
            "project layer must win over user and catalog"
        );
    }

    #[test]
    fn resolve_user_over_catalog() {
        // When the project layer is absent, the user layer must win over the
        // catalog layer.
        let tmp = TempDir::new().unwrap();
        let catalog = write(
            &tmp.path().join("c"),
            "manifest.toml",
            "[style]\nactive = \"trusty-mpm-research\"\n",
        );
        let user = write(
            &tmp.path().join("u"),
            "manifest.toml",
            "[style]\nactive = \"trusty-mpm-teacher\"\n",
        );
        let sources = ManifestSources {
            project: Some(tmp.path().join("p").join("manifest.toml")), // absent
            user: Some(user),
            catalog: Some(catalog),
            language_scope: None,
        };
        let m = resolve_manifest(&sources);
        assert_eq!(
            m.style.and_then(|s| s.active),
            Some("trusty-mpm-teacher".to_string())
        );
    }

    #[test]
    fn resolve_catalog_over_default() {
        // The catalog layer must override the compiled-in default when no higher
        // layer is present.
        let tmp = TempDir::new().unwrap();
        let catalog = write(
            &tmp.path().join("c"),
            "manifest.toml",
            "[mcp]\ntrusty_search = false\n",
        );
        let sources = ManifestSources {
            project: None,
            user: None,
            catalog: Some(catalog),
            language_scope: None,
        };
        let m = resolve_manifest(&sources);
        // The catalog disables search. Because `[mcp]` now merges FIELD-BY-FIELD
        // (not whole-section), the default's `trusty_memory = true` is PRESERVED
        // even though the catalog layer only mentioned `trusty_search`.
        let mcp = m.mcp.expect("mcp section present");
        assert_eq!(mcp.trusty_search, Some(false), "catalog disabled search");
        assert_eq!(
            mcp.trusty_memory,
            Some(true),
            "field-level merge keeps the default's trusty_memory toggle"
        );
    }

    #[test]
    fn resolve_partial_mcp_preserves_other_toggle() {
        // A partial project `[mcp]` override (only trusty_search) must leave the
        // default's trusty_memory toggle intact through the full resolver — the
        // field-level merge regression (whole-section replacement used to null it).
        let tmp = TempDir::new().unwrap();
        let project = write(
            &tmp.path().join("p"),
            "manifest.toml",
            "[mcp]\ntrusty_search = false\n",
        );
        let sources = ManifestSources {
            project: Some(project),
            user: None,
            catalog: None,
            language_scope: None,
        };
        let mcp = resolve_manifest(&sources).mcp.expect("mcp present");
        assert_eq!(mcp.trusty_search, Some(false));
        assert_eq!(
            mcp.trusty_memory,
            Some(true),
            "project partial [mcp] must not reset the default's trusty_memory"
        );
    }

    #[test]
    fn resolve_malformed_layer_is_skipped() {
        // A malformed layer must be skipped (logged), not abort resolution; the
        // lower layers (and ultimately the default) still apply.
        let tmp = TempDir::new().unwrap();
        let user = write(
            &tmp.path().join("u"),
            "manifest.toml",
            "this is not toml {{{",
        );
        let sources = ManifestSources {
            project: None,
            user: Some(user),
            catalog: None,
            language_scope: None,
        };
        // Malformed user layer skipped → result equals the default.
        assert_eq!(resolve_manifest(&sources), default_manifest());
    }

    #[test]
    fn resolve_scopes_agents_for_rust_project() {
        // #1941: a project whose root has a Cargo.toml (and no manifest overrides)
        // must resolve to an agent set that excludes the foreign-language engineers
        // while keeping rust-engineer — driven purely by auto-detection.
        let proj = TempDir::new().unwrap();
        std::fs::write(proj.path().join("Cargo.toml"), "[package]\n").unwrap();
        let fw = TempDir::new().unwrap();
        let catalog = TempDir::new().unwrap();

        let sources = ManifestSources::resolve(proj.path(), fw.path(), catalog.path());
        assert!(
            sources.language_scope.is_some(),
            "a Cargo project must be language-scoped"
        );

        let m = resolve_manifest(&sources);
        let agents = m.agents.expect("agents section present");
        assert!(
            !agents.exclude.contains(&"rust-engineer".to_string()),
            "rust-engineer must survive scoping"
        );
        assert!(
            agents.exclude.contains(&"python-engineer".to_string()),
            "python-engineer must be excluded from a Rust-only project: {:?}",
            agents.exclude
        );
    }

    #[test]
    fn resolve_unknown_project_deploys_everything() {
        // A project with no recognized language marker must NOT be scoped — the
        // resolved manifest keeps the default's empty include/exclude (deploy all).
        let proj = TempDir::new().unwrap();
        std::fs::write(proj.path().join("README.md"), "# hi\n").unwrap();
        let fw = TempDir::new().unwrap();
        let catalog = TempDir::new().unwrap();

        let sources = ManifestSources::resolve(proj.path(), fw.path(), catalog.path());
        assert!(sources.language_scope.is_none());
        let agents = resolve_manifest(&sources).agents.expect("agents present");
        assert!(
            agents.exclude.is_empty(),
            "unknown project excludes nothing"
        );
        assert!(agents.include.is_empty(), "unknown project includes all");
    }
}