trusty-mpm 1.3.4

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
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! Delegation authority — scan deployed agents and render a routing section.
//!
//! Why: the orchestrating Claude Code instance needs to know which agents are
//! deployed and what each one handles so it can route work correctly; that
//! list is dynamic (it depends on what was deployed) and must be regenerated
//! at every session start.
//! What: [`scan_agents`] reads every `.md` file in an agents directory, parses
//! its frontmatter, and returns one [`AgentSummary`] per deployable (non-base)
//! agent; [`generate_authority`] folds those summaries into a Markdown section
//! injected into the session launch instructions;
//! [`resolve_roster`] is THE roster resolver every consumer shares — the PM
//! prompt's delegation section, the `tm session start` count, and `tm doctor`
//! (#4588); [`deployed_roster_section`] renders what it returns (#4069).
//! Test: `delegation_authority_tests.rs` covers scanning, foundation-file
//! exclusion and its near misses, the empty directory, and both render
//! branches.

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

use super::frontmatter::parse_kv_line;

/// A deployed agent as advertised to the orchestrating instance.
///
/// Why: the delegation authority section needs a small, render-ready view of
/// each agent — name, role, what it handles, its foundation chain, and a model
/// hint — without exposing the full composed agent body.
/// What: the display fields parsed from a composed agent's frontmatter plus the
/// resolved `extends` chain (base-first, ending in the agent itself).
/// Test: exercised by every `scan_*` and `generate_*` test.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentSummary {
    /// Agent name (frontmatter `name`, falling back to the file stem).
    pub name: String,
    /// Agent role (frontmatter `role`, falling back to `name`).
    pub role: String,
    /// One-line description of what the agent handles, if declared.
    pub description: Option<String>,
    /// Model hint (frontmatter `model`), if declared.
    pub model: Option<String>,
    /// Resolved inheritance chain, base-first, e.g.
    /// `["base-agent", "base-engineer", "engineer"]`.
    pub extends_chain: Vec<String>,
}

/// File name of the deploy manifest, excluded from agent scans.
const MANIFEST_FILE: &str = "manifest.json";

/// The minimal frontmatter fields a composed agent advertises.
///
/// Why: scanning only needs a handful of YAML keys; a tiny struct avoids a
/// full YAML dependency and keeps parsing explicit.
/// What: the display fields plus `extends` (composed agents normally carry no
/// `extends`, but it is parsed so a hand-written source dir still resolves).
/// Test: exercised indirectly by every `scan_*` test.
#[derive(Debug, Default)]
struct AgentFrontmatter {
    name: Option<String>,
    role: Option<String>,
    description: Option<String>,
    model: Option<String>,
    extends: Option<String>,
}

/// Parse the leading `---` frontmatter block of a Markdown document.
///
/// Why: composed agents store their metadata in a YAML-ish frontmatter block;
/// the scanner reads just the keys it needs without a YAML library.
/// What: if the document opens with a `---` line, collects `key: value` pairs
/// until the closing `---`; quotes are stripped and keys lower-cased. A
/// document with no frontmatter yields an all-`None` result.
/// Test: `scan_finds_agents` (frontmatter present), `scan_handles_no_frontmatter`.
fn parse_frontmatter(raw: &str) -> AgentFrontmatter {
    let trimmed = raw.trim_start_matches(['\u{feff}']);
    let mut lines = trimmed.lines();

    match lines.next() {
        Some(first) if first.trim() == "---" => {}
        _ => return AgentFrontmatter::default(),
    }

    let mut fm = AgentFrontmatter::default();
    for line in lines {
        if line.trim() == "---" {
            break;
        }
        // Use the shared parser so colon-containing values (URLs, timestamps,
        // model ids) are preserved rather than silently truncated.
        let Some((key, value)) = parse_kv_line(line) else {
            continue;
        };
        if value.is_empty() {
            continue;
        }
        match key.as_str() {
            "name" => fm.name = Some(value),
            "role" => fm.role = Some(value),
            "description" => fm.description = Some(value),
            "model" => fm.model = Some(value),
            "extends" => fm.extends = Some(value),
            _ => {}
        }
    }
    fm
}

/// Whether a file stem names a foundation (non-delegatable) template.
///
/// Why (#4589): this is the ONLY rule that removes an agent from the roster, so
/// it must be narrow and it must exist in exactly one place. The `-` is the
/// whole point: `starts_with("base")` silently deletes `baseline-analyzer`,
/// `base64-decoder`, `basecamp-sync` and `based-reviewer` — ordinary names in
/// the project and `~/.claude/agents` tiers tm does not author, which is
/// precisely the failure mode the frontmatter `role:` rule was removed for.
/// Sharing the predicate with the asset-level gate keeps the shipped files and
/// the scanner from drifting apart the way the roster and its count did.
/// What: case-insensitive `base-` prefix on the file stem, so every bundled
/// `BASE-*.md` matches and nothing else plausibly does.
/// Test: `scan_excludes_base_agents`,
/// `near_miss_base_names_are_not_treated_as_foundation_files`.
fn is_foundation_file(stem: &str) -> bool {
    stem.to_ascii_lowercase().starts_with("base-")
}

/// Scan `agents_dir` and return summaries for all non-base agents.
///
/// Why: the orchestrating CC instance needs to know which agents exist
/// and what they handle so it can route work correctly.
///
/// What: reads all .md files in agents_dir, parses frontmatter (name,
/// role, description, model, extends), excludes BASE-* files and the
/// manifest file, returns one AgentSummary per deployable agent.
///
/// Foundation templates are identified by [`is_foundation_file`] — the `BASE-*`
/// FILE NAME convention alone (#4589). An earlier revision also dropped any
/// agent whose frontmatter
/// `role:` began with `base`, which silently deleted three real, dispatchable
/// agents — `memory-manager`, `mpm-agent-manager`, `mpm-skills-manager` — from
/// every roster. `role:` is the wrong signal for the job: the roster is a union
/// over three tiers (see [`deployed_agent_dirs`]) and tm authors the frontmatter
/// in exactly one of them, so no asset edit can stop the same rule from eating
/// an operator's own agent in `<project>/.claude/agents` or `~/.claude/agents`.
/// The file-name convention is tm's own, applies to the files tm actually
/// ships, and is checked before the file is even read.
///
/// Test: `scan_finds_agents`, `scan_excludes_base_agents`,
/// `agent_with_a_base_role_but_no_base_filename_stays_in_the_roster`,
/// `scan_empty_dir`
pub fn scan_agents(agents_dir: &Path) -> Vec<AgentSummary> {
    // An ABSENT tier is normal (not every launch mode populates every tier) and
    // stays silent. Any OTHER enumeration failure — permissions, a bad mount, an
    // I/O fault — is NOT normal and must never be indistinguishable from "empty"
    // (#4069 review): a tier that fails to enumerate silently shrinks the roster,
    // and if every tier fails the prompt reverts to the stale bundled asset with
    // no alarm anywhere. Fail open (a launch must not be blocked by one bad
    // directory) but never fail quiet.
    let entries = match std::fs::read_dir(agents_dir) {
        Ok(entries) => entries,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
        Err(err) => {
            tracing::warn!(
                dir = %agents_dir.display(),
                %err,
                "agent tier could not be enumerated; the delegation roster may be incomplete"
            );
            return Vec::new();
        }
    };

    let mut summaries: Vec<AgentSummary> = Vec::new();
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_file() {
            continue;
        }
        let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
            continue;
        };
        if file_name.eq_ignore_ascii_case(MANIFEST_FILE) {
            continue;
        }
        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
            continue;
        };
        // Exclude BASE-* source/foundation files by file name, before any read.
        // The trailing `-` is load-bearing (#4589 review): a bare `base` prefix
        // also eats `baseline-analyzer`, `base64-decoder`, `basecamp-sync` and
        // `based-reviewer` — plausible agent names in tiers tm does not author,
        // which is the exact failure this rule replaced, not a fix for it.
        if is_foundation_file(stem) {
            // A silent deletion is what made #4589 undiagnosable. An operator
            // whose `base-…` agent vanished has no other thread to pull.
            tracing::debug!(
                file = %file_name,
                dir = %agents_dir.display(),
                "excluded from the delegation roster as a BASE-* foundation template"
            );
            continue;
        }
        if path.extension().and_then(|e| e.to_str()) != Some("md") {
            continue;
        }

        let Ok(raw) = std::fs::read_to_string(&path) else {
            continue;
        };
        let fm = parse_frontmatter(&raw);
        let name = fm.name.clone().unwrap_or_else(|| stem.to_string());
        let role = fm.role.clone().unwrap_or_else(|| name.clone());
        let extends_chain = build_extends_chain(fm.extends.as_deref(), &name);

        summaries.push(AgentSummary {
            name,
            role,
            description: fm.description,
            model: fm.model,
            extends_chain,
        });
    }

    // Deterministic order so the rendered section is stable across runs.
    summaries.sort_by_key(|a| a.name.clone());
    summaries
}

/// Build a base-first inheritance chain from a single `extends` parent.
///
/// Why: composed agents flatten their inheritance into one file and normally
/// carry no `extends`; when one is present we still record the immediate
/// parent so the rendered "Foundation" line is informative.
/// What: returns `[parent, name]` when an `extends` parent exists, otherwise
/// just `[name]`.
/// Test: `scan_finds_agents` asserts the single-element chain.
fn build_extends_chain(extends: Option<&str>, name: &str) -> Vec<String> {
    match extends {
        Some(parent) if !parent.is_empty() => {
            vec![parent.to_string(), name.to_string()]
        }
        _ => vec![name.to_string()],
    }
}

/// Generate the delegation authority Markdown section from summaries.
///
/// Why: injected into session launch instructions so the orchestrating
/// instance knows its delegation options.
///
/// What: produces a Markdown section listing each agent with name,
/// description, extends chain, and model hint.
///
/// Zero-information lines are omitted (#4069 review): `Role` is skipped when it
/// merely repeats the `###` heading (it falls back to `name` when unset, true
/// for ~14 of 40 deployed agents) and `Foundation` is skipped for a
/// single-element chain, which renders as the agent citing itself as its own
/// foundation (true for every composed agent, since composition flattens
/// `extends`). This section is re-emitted into EVERY PM session, so ~2 KB of
/// per-session noise is worth deleting.
///
/// Test: `generate_authority_nonempty`, `generate_authority_empty`,
/// `generate_authority_omits_self_referential_lines`
pub fn generate_authority(agents: &[AgentSummary]) -> String {
    let mut out = String::from("## Delegation Authority\n\n");

    if agents.is_empty() {
        out.push_str(
            "No delegatable agents are currently available. Handle all work \
             directly until agents are deployed.\n",
        );
        return out;
    }

    out.push_str(
        "The following agents are available for delegation. Route work to the\n\
         appropriate agent based on task type.\n\n",
    );

    for agent in agents {
        out.push_str(&format!("### {}\n", agent.name));
        if agent.role != agent.name {
            out.push_str(&format!("- **Role:** {}\n", agent.role));
        }
        let handles = agent
            .description
            .as_deref()
            .unwrap_or("(no description provided)");
        out.push_str(&format!("- **Handles:** {handles}\n"));
        if agent.extends_chain.len() > 1 {
            out.push_str(&format!(
                "- **Foundation:** {}\n",
                agent.extends_chain.join(" → ")
            ));
        }
        if let Some(model) = &agent.model {
            out.push_str(&format!("- **Model:** {model}\n"));
        }
        out.push('\n');
    }

    out
}

/// Agent directories a launched session can load composed agents from,
/// highest precedence first.
///
/// Why (#4069): the roster the PM must route to is whatever Claude Code will
/// actually load, and that is a union of tiers rather than a single directory.
/// Since #4409 every BUNDLED agent deploys into exactly one of them — the
/// tm-owned `CLAUDE_CONFIG_DIR/agents` — while `<project>/.claude/agents` holds
/// only agents the operator hand-placed (and, in future, project-custom
/// trusty-built agents), and `~/.claude/agents` holds whatever the operator's
/// own generic Claude Code install carries, which tm never writes to. All three
/// are still scanned because a session can legitimately resolve an agent from
/// any of them, and the project tier still WINS on a name collision. Prompt
/// composition only ever receives a `project_dir`, so it resolves all three
/// tiers here rather than depending on a launch-mode value it cannot see.
/// What: returns `<project>/.claude/agents`, then the active managed
/// `CLAUDE_CONFIG_DIR/agents` (the `CLAUDE_CONFIG_DIR` env var when set,
/// otherwise [`managed_claude_config_dir`]), then
/// `FrameworkPaths::default().claude_agents_dir()`. Paths are returned whether
/// or not they exist; [`scan_agents`] treats an unreadable directory as empty.
/// Test: `deployed_agent_dirs_puts_project_tier_first`.
pub fn deployed_agent_dirs(project_dir: &Path) -> Vec<PathBuf> {
    let mut dirs = vec![project_dir.join(".claude").join("agents")];

    let managed = std::env::var_os("CLAUDE_CONFIG_DIR")
        .map(PathBuf::from)
        .or_else(crate::core::trusty_tools_config::managed_claude_config_dir);
    if let Some(managed) = managed {
        dirs.push(managed.join("agents"));
    }

    dirs.push(crate::core::paths::FrameworkPaths::default().claude_agents_dir());
    dirs
}

/// Merge the agents found across `dirs` into one roster, earlier dirs winning.
///
/// Why: the tiers returned by [`deployed_agent_dirs`] overlap — the same agent
/// is normally deployed into more than one of them — so a naive concatenation
/// would advertise duplicates. Claude Code resolves a same-named agent by tier
/// precedence, and the rendered roster must describe the copy that actually
/// wins.
/// What: scans each directory in order and keeps the FIRST summary seen per
/// agent name, returning them name-sorted for a stable prompt. The dedup key is
/// LOWER-CASED — agent names are case-insensitive to Claude Code's dispatcher,
/// so `QA` in one tier and `qa` in another are one agent, not two.
/// Test: `roster_from_dirs_dedupes_with_first_dir_winning`,
/// `roster_from_dirs_dedup_is_case_insensitive`.
pub fn roster_from_dirs(dirs: &[PathBuf]) -> Vec<AgentSummary> {
    let mut by_name: BTreeMap<String, AgentSummary> = BTreeMap::new();
    for dir in dirs {
        for agent in scan_agents(dir) {
            by_name
                .entry(agent.name.to_ascii_lowercase())
                .or_insert(agent);
        }
    }
    by_name.into_values().collect()
}

/// THE agent roster for `project_dir`. Every consumer calls this one function.
///
/// Why (#4588): a roster resolved twice is a roster that drifts. The PM prompt
/// took the three-tier union while `tm session start` printed a count taken
/// from a single directory, so the operator was told 34 when the PM had been
/// given 39 — the two numbers were never wrong in the same way at the same
/// time, because they were never the same computation. There is now exactly one
/// implementation, and `tm session start`, the delegation section injected into
/// the PM prompt, and `tm doctor` all read it.
/// What: unions [`deployed_agent_dirs`] via [`roster_from_dirs`], name-sorted
/// and deduplicated with the highest-precedence tier winning.
/// Test: `session_start_count_matches_the_delivered_delegation_roster`
/// (instruction_pipeline_tests) is the cross-consumer agreement gate; the
/// tier-union behaviour itself is covered by the `roster_from_dirs_*` tests.
/// This function consults machine-global tiers, so it has no hermetic direct
/// test of its own.
pub fn resolve_roster(project_dir: &Path) -> Vec<AgentSummary> {
    roster_from_dirs(&deployed_agent_dirs(project_dir))
}

/// Render the LIVE deployed roster for a project, or `None` when none is found.
///
/// Why (#4069): `build_instructions` already computed this section via
/// [`generate_authority`], but its output is merged into a `PipelineOutput`
/// string that the prompt composer never reads — `resolve_pm_prompt` fell back
/// to the static `AGENT_DELEGATION.md` asset, so a 42-agent deployment was
/// advertised to the PM as the asset's hand-maintained 8-row table and agents
/// such as `ticketing` and `memory-manager` were invisible. Regenerating here
/// (rather than threading the pipeline's string down) is the only shape that
/// works for every composer: `build_system_prompt_for*` and its callers —
/// `tm session instructions`, the tmux connect path, the daemon spawn — receive
/// a `project_dir` and nothing else, and two of them never run the pipeline at
/// all, so there is no `PipelineOutput` available to thread.
/// What: resolves the roster via [`resolve_roster`] — the one resolver every
/// consumer shares — and renders it with [`generate_authority`]. Returns `None` when no agent is deployed
/// anywhere, so an unprovisioned environment keeps exactly the previous
/// behaviour (the bundled asset alone) instead of being told it has no agents.
/// Test: `roster_from_dirs_ignores_missing_dirs` (the `None` branch's input) and
/// `instruction_overrides::tests::bundled_delegation_appends_deployed_roster`
/// (the rendered output reaching the delivered prompt). This function itself
/// consults machine-global tiers, so it has no hermetic direct test.
pub fn deployed_roster_section(project_dir: &Path) -> Option<String> {
    let agents = resolve_roster(project_dir);
    if agents.is_empty() {
        // Reverting to the bundled asset must be an OBSERVABLE event, not an
        // invisible one — this is the state #4069 describes, and if it recurs
        // (every tier empty, or every tier unreadable per `scan_agents`'s warn)
        // the operator needs a thread to pull rather than a silently stale
        // 8-name prompt that looks healthy.
        tracing::info!(
            project = %project_dir.display(),
            tiers = deployed_agent_dirs(project_dir).len(),
            "no deployed agents found in any tier; delegation section falls back to the \
             bundled asset alone"
        );
        return None;
    }
    Some(generate_authority(&agents))
}

#[cfg(test)]
#[path = "delegation_authority_tests.rs"]
mod tests;