trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
//! Translate a resolved [`HarnessManifest`] into concrete provisioning inputs.
//!
//! Why: `prepare_session` consumes the resolved manifest, but it should not have
//! to know how each manifest section maps onto a source directory, a selection
//! predicate, or an MCP toggle. Centralizing that translation here keeps
//! `session_launch/mod.rs` focused on the provisioning *sequence* and keeps the
//! manifest→deployment mapping independently testable.
//! What: [`HarnessPlan`] is the materialized provisioning plan derived from a
//! manifest plus the framework/catalog roots — the agent and skill source dirs
//! (bundled vs catalog), the agent/skill selection predicates, the MCP toggles,
//! and the manifest's default output-style id. [`HarnessPlan::from_manifest`]
//! builds it.
//! Test: `plan_default_uses_bundled_sources`, `plan_catalog_source_paths`,
//! `plan_selection_filters`, `plan_mcp_toggles`.

use std::path::PathBuf;

use super::default::{DEFAULT_DIVERT_MIN_LINES, DEFAULT_DIVERT_WORKER_MODEL};
use super::schema::{ContentSource, HarnessManifest, selection_matches};
use crate::core::paths::FrameworkPaths;

/// A materialized provisioning plan derived from a resolved manifest.
///
/// Why: `prepare_session` needs concrete inputs (where to read agents/skills
/// from, which to deploy, which MCP servers to inject, the default style) rather
/// than a manifest it must re-interpret inline; bundling them keeps the launch
/// path readable.
/// What: source directories already resolved to bundled-or-catalog paths, the
/// include/exclude lists for the agent and skill selection predicates, the two
/// MCP toggles (defaulting to on), and the optional manifest-default style id.
/// Test: `plan_default_uses_bundled_sources`, `plan_catalog_source_paths`.
#[derive(Debug, Clone)]
pub struct HarnessPlan {
    /// Directory the agent *source* files are read from.
    pub agent_source: PathBuf,
    /// Agent include patterns (empty = all).
    pub agent_include: Vec<String>,
    /// Agent exclude patterns.
    pub agent_exclude: Vec<String>,
    /// Agent name/glob patterns exempt from catalog-staleness reporting only
    /// (issue #2462). Does NOT affect [`Self::agent_selected`] — an agent
    /// listed here still deploys normally; this only silences the drift
    /// warning for agents the operator has intentionally customized. See
    /// [`super::schema::AgentSet::ignore_staleness`] for the full rationale.
    pub agent_ignore_staleness: Vec<String>,
    /// Directory the skill *source* files are read from.
    pub skill_source: PathBuf,
    /// Skill include patterns (empty = all).
    pub skill_include: Vec<String>,
    /// Skill exclude patterns.
    pub skill_exclude: Vec<String>,
    /// Whether to inject the `trusty-memory` MCP server.
    pub inject_trusty_memory: bool,
    /// Whether to inject the `trusty-search` MCP server.
    pub inject_trusty_search: bool,
    /// The manifest's default output-style id, if it sets one.
    ///
    /// This is the LOWEST-precedence style input: an explicit `--style` flag and
    /// the `[style] active` config key both override it (HR-4 precedence is
    /// preserved by the launch path).
    pub style: Option<String>,
    /// `[divert] enabled` — write the bulk-read diversion hooks for this
    /// session (#6887). An absent `[divert]` section resolves to `false`: the
    /// feature is opt-in, so it is never on by omission.
    pub divert_enabled: bool,
    /// `[divert] min_lines` — line count at or above which a read is diverted.
    pub divert_min_lines: u32,
    /// `[divert] worker_model` — the model the headless worker runs on, always
    /// resolved (an unset or empty key becomes
    /// [`DEFAULT_DIVERT_WORKER_MODEL`]).
    pub divert_worker_model: String,
}

impl HarnessPlan {
    /// Build a provisioning plan from a resolved manifest.
    ///
    /// Why: this is the single place the manifest's declarative sections become
    /// concrete provisioning inputs, so the manifest→deployment mapping can be
    /// audited and tested in one spot.
    /// What: resolves each content source to a directory — `Bundled` uses the
    /// framework's own `agent_source_dir()`/`skill_source_dir()`; `Catalog` uses
    /// `<catalog_root>/repo/.claude/agents` and `…/skills` (the layout
    /// `CatalogSync` writes). Copies the include/exclude lists, reads the MCP
    /// toggles (absent → on, matching the default), and extracts the style id.
    /// Absent sections fall back to bundled-source/all/on so a default manifest
    /// reproduces today's behavior.
    /// Test: `plan_default_uses_bundled_sources`, `plan_catalog_source_paths`,
    /// `plan_mcp_toggles`, `plan_divert_toggles`.
    pub fn from_manifest(
        manifest: &HarnessManifest,
        fw: &FrameworkPaths,
        catalog_root: &std::path::Path,
    ) -> Self {
        let catalog_agents = catalog_root.join("repo").join(".claude").join("agents");
        let catalog_skills = catalog_root.join("repo").join(".claude").join("skills");

        let (agent_source, agent_include, agent_exclude, agent_ignore_staleness) =
            match &manifest.agents {
                Some(set) => {
                    let source = match set.source {
                        ContentSource::Bundled => fw.agent_source_dir(),
                        ContentSource::Catalog => catalog_agents,
                    };
                    (
                        source,
                        set.include.clone(),
                        set.exclude.clone(),
                        set.ignore_staleness.clone(),
                    )
                }
                None => (fw.agent_source_dir(), Vec::new(), Vec::new(), Vec::new()),
            };

        let (skill_source, skill_include, skill_exclude) = match &manifest.skills {
            Some(set) => {
                let source = match set.source {
                    ContentSource::Bundled => fw.skill_source_dir(),
                    ContentSource::Catalog => catalog_skills,
                };
                (source, set.include.clone(), set.exclude.clone())
            }
            None => (fw.skill_source_dir(), Vec::new(), Vec::new()),
        };

        // MCP toggles: absent section or absent flag → on (today's behavior).
        // #7894: no `[mcp.custom]` here — ADR-0042 deleted the one consumer
        // (`session_launch::custom_mcp`), so the plan carried a map nothing
        // read.
        let (inject_trusty_memory, inject_trusty_search) = match &manifest.mcp {
            Some(mcp) => (
                mcp.trusty_memory.unwrap_or(true),
                mcp.trusty_search.unwrap_or(true),
            ),
            None => (true, true),
        };

        let style = manifest.style.as_ref().and_then(|s| s.active.clone());

        // #6887: the divert section is the one whose ABSENT state is OFF. Do
        // not copy the `.unwrap_or(true)` the MCP toggles above use — a
        // manifest that never mentions `[divert]` must leave the feature dark.
        let (divert_enabled, divert_min_lines, divert_worker_model) = match &manifest.divert {
            Some(divert) => (
                divert.enabled.unwrap_or(false),
                divert.min_lines.unwrap_or(DEFAULT_DIVERT_MIN_LINES),
                divert
                    .worker_model
                    .clone()
                    .filter(|m| !m.trim().is_empty())
                    .unwrap_or_else(|| DEFAULT_DIVERT_WORKER_MODEL.to_string()),
            ),
            None => (
                false,
                DEFAULT_DIVERT_MIN_LINES,
                DEFAULT_DIVERT_WORKER_MODEL.to_string(),
            ),
        };

        Self {
            agent_source,
            agent_include,
            agent_exclude,
            agent_ignore_staleness,
            skill_source,
            skill_include,
            skill_exclude,
            inject_trusty_memory,
            inject_trusty_search,
            style,
            divert_enabled,
            divert_min_lines,
            divert_worker_model,
        }
    }

    /// Whether an agent stem is selected by this plan.
    ///
    /// Why: the agent deploy step takes a predicate; exposing the manifest's
    /// include/exclude rule as a method keeps the rule next to the plan it came
    /// from.
    /// What: delegates to [`selection_matches`] with the plan's agent lists.
    /// Test: `plan_selection_filters`.
    pub fn agent_selected(&self, stem: &str) -> bool {
        selection_matches(stem, &self.agent_include, &self.agent_exclude)
    }

    /// Whether an agent stem is exempt from catalog-staleness reporting.
    ///
    /// Why (issue #2462): `detect_staleness` must be able to silence the drift
    /// warning for an agent the operator deliberately customized WITHOUT that
    /// exemption also removing the agent from [`Self::agent_selected`]'s deploy
    /// roster — the two concerns are independent (see
    /// [`super::schema::AgentSet::ignore_staleness`]).
    /// What: `true` iff `stem` matches any `agent_ignore_staleness` pattern.
    /// Test: `plan_ignore_staleness_does_not_affect_agent_selected`.
    pub fn agent_staleness_ignored(&self, stem: &str) -> bool {
        super::schema::matches_any(stem, &self.agent_ignore_staleness)
    }

    /// Whether a skill stem is selected by this plan.
    ///
    /// Why/What/Test: as [`agent_selected`](Self::agent_selected), for skills.
    pub fn skill_selected(&self, stem: &str) -> bool {
        selection_matches(stem, &self.skill_include, &self.skill_exclude)
    }
}

#[cfg(test)]
mod tests {
    use super::super::default::default_manifest;
    use super::super::schema::{AgentSet, McpServers, SkillSet, StyleSelection};
    use super::*;

    #[test]
    fn plan_default_uses_bundled_sources() {
        // The compiled-in default manifest must plan bundled sources, no filter,
        // both MCP servers on, and the professional style — i.e. today's path.
        let fw = FrameworkPaths::under("/base");
        let plan = HarnessPlan::from_manifest(
            &default_manifest(),
            &fw,
            std::path::Path::new("/home/.trusty-mpm/catalog"),
        );
        assert_eq!(plan.agent_source, fw.agent_source_dir());
        assert_eq!(plan.skill_source, fw.skill_source_dir());
        assert!(plan.agent_include.is_empty());
        assert!(plan.agent_selected("anything"));
        assert!(plan.skill_selected("anything"));
        assert!(plan.inject_trusty_memory);
        assert!(plan.inject_trusty_search);
        assert_eq!(plan.style.as_deref(), Some("trusty-mpm"));
    }

    #[test]
    fn plan_catalog_source_paths() {
        // A manifest pointing agents at the catalog must resolve to the
        // <catalog>/repo/.claude/agents layout CatalogSync writes.
        let fw = FrameworkPaths::under("/base");
        let manifest = HarnessManifest {
            agents: Some(AgentSet {
                include: vec![],
                exclude: vec![],
                source: ContentSource::Catalog,
                ..AgentSet::default()
            }),
            ..default_manifest()
        };
        let plan = HarnessPlan::from_manifest(
            &manifest,
            &fw,
            std::path::Path::new("/home/.trusty-mpm/catalog"),
        );
        assert_eq!(
            plan.agent_source,
            PathBuf::from("/home/.trusty-mpm/catalog/repo/.claude/agents")
        );
        // skills stayed bundled (default manifest's skills section).
        assert_eq!(plan.skill_source, fw.skill_source_dir());
    }

    #[test]
    fn plan_selection_filters() {
        // include/exclude on the manifest must drive the selection predicates.
        let fw = FrameworkPaths::under("/base");
        let manifest = HarnessManifest {
            agents: Some(AgentSet {
                include: vec!["*-engineer".into()],
                exclude: vec!["php-engineer".into()],
                source: ContentSource::Bundled,
                ..AgentSet::default()
            }),
            skills: Some(SkillSet {
                include: vec!["example-skill".into()],
                exclude: vec![],
                source: ContentSource::Bundled,
            }),
            ..default_manifest()
        };
        let plan = HarnessPlan::from_manifest(&manifest, &fw, std::path::Path::new("/c"));
        assert!(plan.agent_selected("rust-engineer"));
        assert!(!plan.agent_selected("php-engineer"), "exclude wins");
        assert!(!plan.agent_selected("qa"), "not in include");
        assert!(plan.skill_selected("example-skill"));
        assert!(!plan.skill_selected("tm-doctor"));
    }

    #[test]
    fn plan_ignore_staleness_does_not_affect_agent_selected() {
        // Issue #2462: `ignore_staleness` must silence the staleness-only
        // predicate while leaving `agent_selected` (the deploy roster) fully
        // unaffected — the exact confusion that let `exclude = ["engineer"]`
        // (meant only to silence /health) silently drop `engineer.md` from
        // every session-local `.claude/agents/`.
        let fw = FrameworkPaths::under("/base");
        let manifest = HarnessManifest {
            agents: Some(AgentSet {
                include: vec![],
                exclude: vec![],
                ignore_staleness: vec!["engineer".into()],
                source: ContentSource::Bundled,
            }),
            ..default_manifest()
        };
        let plan = HarnessPlan::from_manifest(&manifest, &fw, std::path::Path::new("/c"));
        assert!(
            plan.agent_selected("engineer"),
            "ignore_staleness must not remove an agent from the deploy roster"
        );
        assert!(plan.agent_staleness_ignored("engineer"));
        assert!(
            !plan.agent_staleness_ignored("qa"),
            "unrelated agent unaffected"
        );
    }

    #[test]
    fn plan_mcp_toggles() {
        // Disabling an MCP server in the manifest must turn off injection.
        let fw = FrameworkPaths::under("/base");
        let manifest = HarnessManifest {
            mcp: Some(McpServers {
                trusty_memory: Some(false),
                trusty_search: Some(true),
            }),
            ..default_manifest()
        };
        let plan = HarnessPlan::from_manifest(&manifest, &fw, std::path::Path::new("/c"));
        assert!(!plan.inject_trusty_memory);
        assert!(plan.inject_trusty_search);
    }

    /// Why (#6887): the plan is where "absent section" becomes a concrete
    /// value, and it is the one place the opt-in default could be lost by
    /// copying the `[mcp]` toggles' `.unwrap_or(true)`. This asserts the three
    /// states that matter: absent, partial, full.
    /// What: absent `[divert]` -> `(false, 350, "claude-haiku-4-5")`; a partial
    /// section that sets only `enabled` keeps the other defaults; a full
    /// section reaches the plan verbatim.
    #[test]
    fn plan_divert_toggles() {
        use super::super::schema::DivertConfig;
        let fw = FrameworkPaths::under("/base");
        let catalog = std::path::Path::new("/c");

        // Absent section: the feature is dark, with the documented fallbacks.
        let absent = HarnessManifest {
            divert: None,
            ..HarnessManifest::default()
        };
        let plan = HarnessPlan::from_manifest(&absent, &fw, catalog);
        assert!(!plan.divert_enabled, "an absent section must not enable it");
        assert_eq!(plan.divert_min_lines, 350);
        assert_eq!(plan.divert_worker_model, "claude-haiku-4-5");

        // Partial section: only `enabled` set; the rest keep their defaults.
        let partial = HarnessManifest {
            divert: Some(DivertConfig {
                enabled: Some(true),
                ..DivertConfig::default()
            }),
            ..HarnessManifest::default()
        };
        let plan = HarnessPlan::from_manifest(&partial, &fw, catalog);
        assert!(plan.divert_enabled);
        assert_eq!(plan.divert_min_lines, 350);
        assert_eq!(plan.divert_worker_model, "claude-haiku-4-5");

        // The compiled-in default states it off explicitly.
        let plan = HarnessPlan::from_manifest(&default_manifest(), &fw, catalog);
        assert!(!plan.divert_enabled);
        assert_eq!(plan.divert_worker_model, "claude-haiku-4-5");

        // Full section: every field reaches the plan verbatim.
        let full = HarnessManifest {
            divert: Some(DivertConfig {
                enabled: Some(true),
                min_lines: Some(120),
                worker_model: Some("claude-haiku-4-5-20251001".to_string()),
            }),
            ..HarnessManifest::default()
        };
        let plan = HarnessPlan::from_manifest(&full, &fw, catalog);
        assert!(plan.divert_enabled);
        assert_eq!(plan.divert_min_lines, 120);
        assert_eq!(plan.divert_worker_model, "claude-haiku-4-5-20251001");
    }

    #[test]
    fn a_custom_mcp_table_reaches_no_part_of_the_plan() {
        // #7894: `[mcp.custom]` resolved into `HarnessPlan.custom_mcp_servers`
        // and stopped there — its only consumer,
        // `session_launch::custom_mcp::inject_custom_trusty_mcps`, was deleted
        // by ADR-0042 along with every other workspace `.mcp.json` injector.
        // The plan is what `prepare_session` reads, so asserting the name
        // appears nowhere in it is the whole claim: a re-added field carrying
        // the table would fail here rather than pass unnoticed.
        let fw = FrameworkPaths::under("/base");
        let manifest = HarnessManifest::from_toml(
            "[mcp.custom.duetto-memory]\ntype = \"http\"\nurl = \"https://mcp.example/memory\"\n",
        )
        .expect("a manifest declaring [mcp.custom] must still parse");

        let plan = HarnessPlan::from_manifest(&manifest, &fw, std::path::Path::new("/c"));

        assert!(
            !format!("{plan:?}").contains("duetto-memory"),
            "no part of the plan may carry a [mcp.custom] entry: {plan:?}"
        );
    }

    #[test]
    fn plan_style_default_lowest_precedence() {
        // The manifest style is exposed as the plan's style; an absent style
        // section yields None.
        let fw = FrameworkPaths::under("/base");
        let manifest = HarnessManifest {
            style: Some(StyleSelection {
                active: Some("trusty-mpm-research".into()),
            }),
            ..HarnessManifest::default()
        };
        let plan = HarnessPlan::from_manifest(&manifest, &fw, std::path::Path::new("/c"));
        assert_eq!(plan.style.as_deref(), Some("trusty-mpm-research"));

        let none_plan = HarnessPlan::from_manifest(
            &HarnessManifest::default(),
            &fw,
            std::path::Path::new("/c"),
        );
        assert_eq!(none_plan.style, None);
    }
}