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
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
//! Multi-style output support with version-fallback injection (HR-4).
//!
//! Why: trusty-mpm bundles three Claude Code output styles — professional
//! (`trusty-mpm`), teaching (`trusty-mpm-teacher`), and research
//! (`trusty-mpm-research`). The operator picks the active one via config
//! (`[style] active = …`) or the launch `--style` flag. Modern Claude Code
//! (≥ 1.0.83) honours a native `"outputStyle"` key in `.claude/settings.json`,
//! but older builds ignore it; for those, trusty-mpm must INJECT the active
//! style's content into the PM system prompt instead so the active style is in
//! effect regardless of Claude Code version (DOC-17 §HR-4).
//! What: a registry resolver over [`crate::core::bundle::OUTPUT_STYLES`]
//! ([`resolve_style`], [`resolve_active_style`]), a `[style]` config section
//! ([`StyleConfig`]), Claude Code version detection
//! ([`claude_supports_native_output_style`], [`version_supports_native`]), and
//! prompt injection ([`inject_style_into_prompt`]).
//! Test: `output_style_tests.rs` covers registry resolution (all three ids +
//! unknown-id error), config selection, the version gate (mocked version
//! strings), and prompt injection.

use std::path::Path;

use crate::core::bundle::{BundledStyle, DEFAULT_OUTPUT_STYLE_ID, OUTPUT_STYLES};
use crate::core::config::MpmConfig;
use crate::core::framework_content::FrameworkContent;

/// The first Claude Code version that honours the native `outputStyle` settings
/// key, as `(major, minor, patch)`.
///
/// Why: claude-mpm established `1.0.83` as the floor for native output-style
/// support; trusty-mpm mirrors it so the version gate matches upstream behaviour.
/// What: the inclusive lower bound — a detected version `>=` this supports native
/// output styles.
/// Test: `version_gate_*` in `output_style_tests.rs`.
pub const NATIVE_OUTPUT_STYLE_MIN_VERSION: (u32, u32, u32) = (1, 0, 83);

/// How long [`claude_supports_native_output_style`] waits for `claude
/// --version` before giving up on it.
///
/// Why: the probe sits on the session-launch and prompt-composition paths, and
/// a wedged `claude` binary used to hang them forever (#5969). `claude
/// --version` answers in well under a second on a healthy host, so five
/// seconds is generous for the answer and short enough that a wedged binary
/// costs a launch five seconds rather than the whole session.
/// What: the deadline handed to
/// [`crate::core::spawn_disclaim::disclaimed_stdout_with_timeout`]; past it the
/// probe child is killed and the probe fails safe to "no native support".
/// Test: `spawn_disclaim::timeout`'s
/// `disclaimed_stdout_with_timeout_kills_and_reaps_wedged_child`.
pub const CLAUDE_VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);

/// A failure resolving a requested output-style id.
///
/// Why: an unknown style id is a user-facing error (a typo in config or the
/// `--style` flag); callers need a typed surface that names the bad id and the
/// valid set so the message is actionable.
/// What: a single variant carrying the requested id.
/// Test: `unknown_style_id_errors` in `output_style_tests.rs`.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum StyleError {
    /// The requested style id is not in the bundled registry.
    #[error("unknown output style '{requested}'; valid styles: {valid}")]
    Unknown {
        /// The id the caller asked for.
        requested: String,
        /// Comma-separated list of valid ids (for the error message).
        valid: String,
    },
}

/// Resolve a style id to its bundled entry, erroring on an unknown id.
///
/// Why: the launch path must validate a configured/selected id against the
/// bundle before writing it into `.claude/settings.json` — Claude Code silently
/// falls back to the operator's default on a name mismatch, so a typo would
/// otherwise produce a confusing "style not applied" with no error.
/// What: linear-scans [`OUTPUT_STYLES`] for `id`; returns the matching
/// [`BundledStyle`] or [`StyleError::Unknown`] listing the valid ids.
/// Test: `resolve_style_resolves_all_three_ids`, `unknown_style_id_errors`.
pub fn resolve_style(id: &str) -> Result<&'static BundledStyle, StyleError> {
    OUTPUT_STYLES
        .iter()
        .find(|s| s.id == id)
        .ok_or_else(|| StyleError::Unknown {
            requested: id.to_string(),
            valid: valid_style_ids(),
        })
}

/// Comma-separated list of bundled style ids, in registry order.
///
/// Why: used in the [`StyleError::Unknown`] message and exposed so the CLI can
/// list the choices for `--style`.
/// What: joins every [`BundledStyle::id`] with `", "`.
/// Test: `valid_style_ids_lists_all`.
pub fn valid_style_ids() -> String {
    OUTPUT_STYLES
        .iter()
        .map(|s| s.id)
        .collect::<Vec<_>>()
        .join(", ")
}

/// `[style]` section of `~/.trusty-mpm/config.toml`.
///
/// Why: HR-4 makes the active output style configurable; this section records
/// the operator's choice so every launch picks it up without a flag.
/// What: an optional `active` id. `None` (absent section/key) means "use the
/// professional default" ([`DEFAULT_OUTPUT_STYLE_ID`]).
/// Test: `config_style_section_parses`, `active_style_id_defaults_when_unset`.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
pub struct StyleConfig {
    /// The active output-style id (e.g. `"trusty-mpm-teacher"`). `None` → default.
    pub active: Option<String>,
}

/// Determine the effective active style id from config + an optional override.
///
/// Why: the active style can come from the launch `--style` flag (highest
/// precedence) or the `[style] active` config key, falling back to the
/// professional default; centralizing the precedence keeps the CLI and the
/// client launch paths consistent.
/// What: returns the `explicit` override if `Some`, else `config.style.active`
/// if set, else [`DEFAULT_OUTPUT_STYLE_ID`]. Does NOT validate the id — callers
/// validate via [`resolve_style`] (or [`resolve_active_style`]) so the
/// unknown-id error surfaces once, with full context.
/// Test: `active_style_id_precedence`.
pub fn active_style_id<'a>(config: &'a MpmConfig, explicit: Option<&'a str>) -> &'a str {
    if let Some(id) = explicit {
        return id;
    }
    config
        .style
        .active
        .as_deref()
        .unwrap_or(DEFAULT_OUTPUT_STYLE_ID)
}

/// Resolve the active style to a bundled entry, applying config + override.
///
/// Why: the launch path needs the actual [`BundledStyle`] (id + content) for the
/// active selection, validated; this combines [`active_style_id`] with
/// [`resolve_style`].
/// What: computes the active id via [`active_style_id`], then resolves it.
/// Returns [`StyleError::Unknown`] when the selected id is not bundled.
/// Test: `resolve_active_style_uses_config`, `resolve_active_style_unknown_errors`.
pub fn resolve_active_style(
    config: &MpmConfig,
    explicit: Option<&str>,
) -> Result<&'static BundledStyle, StyleError> {
    resolve_style(active_style_id(config, explicit))
}

/// Parse a `claude --version` output line into `(major, minor, patch)`.
///
/// Why: the version gate must turn the raw CLI output (e.g.
/// `"1.0.84 (Claude Code)"`) into a comparable tuple; isolating the parse makes
/// it testable without spawning the binary.
/// What: extracts the first `\d+.\d+.\d+` triple from `raw`. Returns `None` when
/// no such triple is present (so callers can treat unparseable output as "assume
/// no native support" to stay safe).
/// Test: `parse_version_*`.
pub fn parse_claude_version(raw: &str) -> Option<(u32, u32, u32)> {
    // Find the first whitespace/paren-delimited token that looks like a semver
    // triple. We scan token-by-token rather than pull in a regex dependency.
    for token in raw.split(|c: char| c.is_whitespace() || c == '(' || c == ')' || c == 'v') {
        if let Some(v) = parse_triple(token) {
            return Some(v);
        }
    }
    None
}

/// Parse a single token as a `major.minor.patch` triple.
///
/// Why: helper for [`parse_claude_version`]; a token may carry a pre-release or
/// build suffix (`1.0.84-beta`), so we split on the first non-digit after the
/// patch number.
/// What: splits `token` on `.`; requires at least three leading numeric
/// components; ignores any suffix on the patch component.
/// Test: covered via `parse_version_*`.
fn parse_triple(token: &str) -> Option<(u32, u32, u32)> {
    let mut parts = token.split('.');
    let major = parts.next()?.parse::<u32>().ok()?;
    let minor = parts.next()?.parse::<u32>().ok()?;
    // Patch may have a trailing suffix (e.g. `83-beta`); take the leading digits.
    let patch_raw = parts.next()?;
    let patch_digits: String = patch_raw
        .chars()
        .take_while(|c| c.is_ascii_digit())
        .collect();
    if patch_digits.is_empty() {
        return None;
    }
    let patch = patch_digits.parse::<u32>().ok()?;
    Some((major, minor, patch))
}

/// Decide whether a detected version supports the native `outputStyle` key.
///
/// Why: the launch path branches on this — native key vs. prompt injection.
/// What: returns `true` when `version >= ` [`NATIVE_OUTPUT_STYLE_MIN_VERSION`].
/// Test: `version_gate_supported`, `version_gate_unsupported`,
/// `version_gate_exact_floor`.
pub fn version_supports_native(version: (u32, u32, u32)) -> bool {
    version >= NATIVE_OUTPUT_STYLE_MIN_VERSION
}

/// Detect whether the installed Claude Code supports native output styles.
///
/// Why: when the installed CLI is too old (or undetectable) trusty-mpm must
/// inject the style into the PM prompt instead of relying on the settings key.
/// What: runs `claude --version`, parses the first semver triple, and compares
/// it to [`NATIVE_OUTPUT_STYLE_MIN_VERSION`]. **Fails safe**: any error (binary
/// missing, non-zero exit, unparseable output, or a `claude` that never exits)
/// is logged to stderr and treated as "does NOT support native" so the style is
/// always applied via injection. All logging goes to stderr (the daemon/MCP
/// stdout-clean rule).
/// Test: pure logic is in [`version_supports_native`] /
/// [`detect_native_support_from_output`]; this thin wrapper is side-effecting
/// (spawns a process) and is covered by `detect_from_output_*`. The bounded
/// wait itself is covered by `spawn_disclaim::timeout`'s tests.
pub fn claude_supports_native_output_style() -> bool {
    // Spawn through the disclaim helper so this direct child of the signed
    // daemon is its own TCC responsible process, not `trusty-mpm` (issue #2819).
    // #5969: bound the wait — an unbounded one hung every session launch and
    // prompt-composition path when `claude --version` wedged.
    match crate::core::spawn_disclaim::disclaimed_stdout_with_timeout(
        "claude",
        &["--version".to_string()],
        CLAUDE_VERSION_PROBE_TIMEOUT,
    ) {
        Ok(out) if out.status.success() => {
            let stdout = String::from_utf8_lossy(&out.stdout);
            detect_native_support_from_output(&stdout)
        }
        Ok(out) => {
            let code = out.status.code().unwrap_or(-1);
            tracing::warn!(
                exit = code,
                "`claude --version` exited non-zero; assuming no native output-style support"
            );
            false
        }
        Err(err) => {
            tracing::warn!(
                %err,
                "could not run `claude --version`; assuming no native output-style support"
            );
            false
        }
    }
}

/// Map a `claude --version` output string to native-support, failing safe.
///
/// Why: separating the string→bool decision from the process spawn makes the
/// version gate fully unit-testable with mocked version strings (the HR-4
/// acceptance criterion).
/// What: parses `raw` via [`parse_claude_version`]; an unparseable string yields
/// `false` (assume no native support → inject). A parsed version defers to
/// [`version_supports_native`].
/// Test: `detect_from_output_modern`, `detect_from_output_old`,
/// `detect_from_output_unparseable`.
pub fn detect_native_support_from_output(raw: &str) -> bool {
    match parse_claude_version(raw) {
        Some(v) => version_supports_native(v),
        None => {
            tracing::warn!(
                output = raw.trim(),
                "could not parse Claude Code version; assuming no native output-style support"
            );
            false
        }
    }
}

/// Strip a leading YAML frontmatter block (`--- … ---`) from style content.
///
/// Why: when injecting a style into the PM system prompt, the
/// `name:`/`description:` frontmatter is Claude-Code settings metadata, not
/// instructions for the model; stripping it keeps the injected text clean.
/// What: if `content` starts with a `---` line, drops everything through the
/// closing `---` line; otherwise returns `content` unchanged (trimmed).
/// Test: `strip_frontmatter_removes_block`, `strip_frontmatter_passthrough`.
fn strip_frontmatter(content: &str) -> &str {
    let trimmed = content.trim_start();
    if let Some(rest) = trimmed.strip_prefix("---") {
        // `rest` begins right after the opening `---`. Find the closing fence.
        if let Some(end) = rest.find("\n---") {
            // Skip past the closing `---` line.
            let after = &rest[end + "\n---".len()..];
            // Drop the remainder of the closing fence line.
            if let Some(nl) = after.find('\n') {
                return after[nl + 1..].trim_start();
            }
            return after.trim_start();
        }
    }
    trimmed
}

/// Header that delimits an injected output-style block in the PM prompt.
///
/// Why: a clearly-labelled fence tells the launched PM (and a human reading the
/// stashed prompt) that the block is the active output style, injected because
/// the installed Claude Code lacks native support.
/// What: the Markdown heading prepended to the injected style body.
/// Test: `inject_prepends_style_block`.
pub const INJECTED_STYLE_HEADING: &str =
    "# Active Output Style (injected — Claude Code lacks native outputStyle support)";

/// Prepend an output style's content to a PM system prompt.
///
/// Why: on older Claude Code the `"outputStyle"` settings key is ignored, so the
/// only way to make the active style take effect is to fold its instructions
/// into the `--append-system-prompt` text. claude-mpm does the same.
/// What: returns a new string of `INJECTED_STYLE_HEADING`, then the style body
/// (frontmatter stripped), then the framework section separator, then the
/// original `prompt`. The original prompt is preserved verbatim so the PM floor
/// and overrides are untouched — the style is purely additive and placed first.
/// Test: `inject_prepends_style_block`, `inject_preserves_prompt`.
pub fn inject_style_into_prompt(
    content: &FrameworkContent,
    style: &BundledStyle,
    prompt: &str,
) -> String {
    inject_style_text(style.content(content), prompt)
}

/// [`inject_style_into_prompt`] for a style's raw text, bundled or project (#8533).
///
/// Test: `inject_prepends_style_block`,
/// `a_project_style_keeps_the_floor_with_and_without_native_support`.
pub fn inject_style_text(content: &str, prompt: &str) -> String {
    inject_body(strip_frontmatter(content).trim(), prompt)
}

/// `body` (frontmatter already stripped) under [`INJECTED_STYLE_HEADING`], then `prompt`.
fn inject_body(body: &str, prompt: &str) -> String {
    format!(
        "{INJECTED_STYLE_HEADING}\n\n{body}{sep}{prompt}",
        sep = crate::core::instruction_pipeline::SECTION_SEPARATOR,
    )
}

/// Resolve the active style and inject it into `prompt` only when the installed
/// Claude Code lacks native output-style support.
///
/// Why: this is the single launch-time seam (`build_system_prompt_for` calls it)
/// that makes the active style effective regardless of Claude Code version: on a
/// modern CLI the prompt is returned unchanged (native key handles the style); on
/// an old/undetectable CLI the style is injected. Keeping it here means both the
/// CLI and client launch paths get identical behaviour.
/// What: if [`claude_supports_native_output_style`] is `true`, returns `prompt`
/// unchanged. Otherwise resolves the active style (config + `explicit`
/// override); on success injects it via [`inject_style_into_prompt`]; on an
/// unknown id logs a warning to stderr and falls back to the professional
/// default style (per DOC-17: unknown style → `trusty-mpm` default). When the
/// home dir / config is unavailable the caller passes [`MpmConfig::default`].
/// Test: `maybe_inject_*` in `output_style_tests.rs` drive the gate via the
/// injected `native_supported` flag.
pub fn maybe_inject_active_style(
    content: &FrameworkContent,
    config: &MpmConfig,
    explicit: Option<&str>,
    prompt: String,
    native_supported: bool,
) -> String {
    if native_supported {
        return prompt;
    }
    let style = match resolve_active_style(config, explicit) {
        Ok(s) => s,
        Err(err) => {
            tracing::warn!(
                %err,
                "falling back to the default output style for prompt injection"
            );
            // Default is always present in the registry; resolve it directly.
            match resolve_style(DEFAULT_OUTPUT_STYLE_ID) {
                Ok(s) => s,
                Err(_) => return prompt, // unreachable: default is always bundled
            }
        }
    };
    inject_style_into_prompt(content, style, &prompt)
}

/// Convenience: resolve the active style id for a project, reading the user
/// config and gating on the live Claude Code version.
///
/// Why: the launch sites want a one-call entry point that does the real work
/// (load config, detect version, inject) without each re-implementing the
/// precedence; this wraps [`maybe_inject_active_style`] with the real config and
/// live version detection.
/// What: loads [`MpmConfig::load_default`], detects native support via
/// [`claude_supports_native_output_style`], and injects (or not) into `prompt`.
/// `_project_dir` is accepted for symmetry with the other launch helpers and to
/// allow future project-scoped style overrides; it is currently unused.
/// Test: side-effecting (loads real config, spawns `claude`); the pure core is
/// covered by `maybe_inject_*`.
pub fn apply_output_style_to_prompt(
    content: &FrameworkContent,
    project_dir: &Path,
    explicit: Option<&str>,
    prompt: String,
) -> String {
    let native = claude_supports_native_output_style();
    apply_output_style_to_prompt_with_native(content, project_dir, explicit, prompt, native)
}

/// Apply the output-style injection decision with the `native_supported` flag
/// supplied explicitly (no live `claude --version` probe).
///
/// Why: [`apply_output_style_to_prompt`] spawns `claude --version`, which makes
/// every caller (and every test that exercises a launch-prompt path) depend on
/// whether `claude` is installed on the host — the exact host-dependence that
/// broke `prepare_session_stash_reflects_override` on CI (issue #1409). This
/// seam lets tests pin the decision BOTH ways while production still does real
/// version detection (and fails safe to injection) via the wrapper above.
/// What: selects the style [`select_style_under`] picks for the default
/// framework root. When `native_supported`, Claude Code delivers the style
/// file itself, so `prompt` is returned unchanged — preceded by the
/// [`style_floor`] for a project style whose composite is not active
/// ([`composite_is_active`]). Otherwise the style's
/// [`delivered_style_text`] (a project style's prose plus the floor) is
/// injected ahead of `prompt`.
/// #8533: the style is chosen by [`effective_style_id`] and resolved
/// against the project's own style files too, so a project style reaches an
/// older Claude Code; an unknown id warns and injects the default.
/// Test: `a_project_style_keeps_the_floor_with_and_without_native_support`,
/// `a_bundled_style_gets_no_second_floor_on_either_path`; the
/// stash/launch invariant under both flag values is covered by
/// `prepare_session_stash_reflects_override` in `session_launch/tests.rs`.
pub fn apply_output_style_to_prompt_with_native(
    content: &FrameworkContent,
    project_dir: &Path,
    explicit: Option<&str>,
    prompt: String,
    native_supported: bool,
) -> String {
    // #8533: the same selector the launch and `tm sessions instructions` use,
    // manifest tier included.
    let root = crate::core::paths::FrameworkPaths::default().root;
    let selected = select_style_under(&root, project_dir, explicit);
    apply_selected_style(content, project_dir, &selected, prompt, native_supported)
}

/// [`apply_output_style_to_prompt_with_native`] for an already-resolved
/// profile (#8453): the launch passes the one value it resolved.
pub fn apply_output_style_to_prompt_for(
    content: &FrameworkContent,
    project_dir: &Path,
    explicit: Option<&str>,
    prompt: String,
    native_supported: bool,
    profile: crate::core::session_profile::SessionProfile,
) -> String {
    let root = crate::core::paths::FrameworkPaths::default().root;
    let selected = project::select_style_under_for(&root, project_dir, explicit, profile);
    apply_selected_style(content, project_dir, &selected, prompt, native_supported)
}

/// The injection decision for a selected style; see
/// [`apply_output_style_to_prompt_with_native`].
fn apply_selected_style(
    content: &FrameworkContent,
    project_dir: &Path,
    selected: &SelectedStyle,
    prompt: String,
    native_supported: bool,
) -> String {
    if native_supported {
        // #8533: Claude Code reads the style file `outputStyle` names. When that
        // is the current composite, the floor is already in it; otherwise the
        // floor heads the appended prompt.
        return match floor_for(content, &selected.style) {
            Some(floor) if !composite_is_active(content, project_dir, &selected.style) => format!(
                "{floor}{sep}{prompt}",
                sep = crate::core::instruction_pipeline::SECTION_SEPARATOR,
            ),
            _ => prompt,
        };
    }
    inject_body(&delivered_style_text(content, &selected.style), &prompt)
}

// #8533: the floor appended to a project style (owner ruling 2026-09-25).
#[path = "output_style_floor.rs"]
mod floor;
pub use floor::{
    COMPOSITE_STYLE_SUFFIX, FLOOR_SECTIONS, STYLE_FLOOR_HEADING, composite_is_active,
    composite_path, composite_style_id, composite_style_text, delivered_style_text, floor_for,
    is_composite_style_id, native_style_id, style_floor,
};

// #8533: project-local styles live in a child module (SLOC headroom).
#[path = "output_style_project.rs"]
mod project;
pub use project::{
    ActiveStyle, PROJECT_STYLES_DIR, ProjectStyleError, SelectedStyle, describe_effective_style,
    effective_style_id, manifest_style_id, project_selected_style, project_style_ids,
    resolve_or_default, resolve_style_in_project, select_style, select_style_under,
    select_style_under_for,
};

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