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
//! Per-project stack profile derived from detected language markers (#1971).
//!
//! Why: the PM must be primed with THIS project's stack, never a hardcoded
//! default. Historically the deployed output style declared a "Rust workspace"
//! for every project, so a Next.js/TypeScript project (ai-power-rankings) was
//! told to route to `rust-engineer` and run `cargo` — the PM had to notice and
//! self-correct. Bob's normative requirement: no default stack profile; each
//! project's PM priming is configured from that project's detected stack, and an
//! undetected stack yields a NEUTRAL profile that mandates detection before
//! routing. The bundled output styles are now stack-neutral (they carry no
//! default); this module supplies the concrete, per-project half: a
//! **Detected Project Stack** section folded into the per-project PM prompt at
//! session-prepare time.
//! What: [`stack_profile_section`] probes `project_dir` via the shared
//! [`crate::core::manifest::framework::detected_stack_engineers`] marker
//! detection
//! (single source of truth, also used for agent-roster scoping) and renders a
//! Markdown section — either the detected language engineers (route code work to
//! them, use the project's own quality gate) or a neutral "not auto-detected"
//! block that forbids defaulting and requires a Research pass. [`resolve_pm_prompt`](crate::core::instruction_overrides::resolve_pm_prompt)
//! ([`crate::core::instruction_overrides`]) slots the section into the prompt.
//! Either body carries [`TRUNCATED_NOTE`] when a RESOURCE cap cut detection
//! short (#7781), so a partial list is never read as the whole stack, and
//! [`depth_note`] when the walk stopped at its declared depth.
//! Test: `detected_rust_lists_rust_engineer`, `detected_nextjs_lists_ts_family`,
//! `detected_polyglot_lists_both_families`, `undetected_is_neutral_no_default`,
//! `heading_present_in_both_modes`, `truncated_detection_says_so`,
//! `depth_limited_detection_says_so`.

use std::path::Path;

use crate::core::manifest::framework::{
    MAX_NESTED_DEPTH, StackDetection, detected_stack_engineers,
};

/// Heading that delimits the auto-derived stack-profile block in the PM prompt.
///
/// Why: a stable, greppable heading lets the launched PM (and tests, and a human
/// reading `.trusty-mpm/last-instructions.md`) locate the authoritative per-project
/// stack statement, and lets [`crate::core::instruction_overrides`] assert it is
/// present.
/// What: the Markdown `##` heading prepended to the detected-stack body.
/// Test: `heading_present_in_both_modes`.
pub const STACK_PROFILE_HEADING: &str = "## Detected Project Stack (auto-derived)";

/// The one line appended when a RESOURCE cap cut stack detection short (#7781).
///
/// Why: the scan is bounded by directories scanned and a member cap, and each
/// one fails closed by returning fewer engineers. Without this line the PM reads
/// a partial list as the whole stack and routes on it. The DEPTH bound does not
/// render here — round-3 found it trips on most repositories, so folding it in
/// made this warning near-universal and therefore ignorable.
/// What: a single sentence appended to whichever body was rendered, detected or
/// neutral. The cap that tripped is in the daemon log, not the prompt — the
/// PM needs to know the list is partial, not which constant to raise.
/// Test: `truncated_detection_says_so`.
const TRUNCATED_NOTE: &str = "\n\n**Detection was truncated** — a scan bound \
     (directories scanned, or the member cap) stopped the walk before the whole \
     tree was read, so a stack kept in an unscanned subdirectory is missing \
     from this section. Treat the list as partial and confirm with a Research \
     pass before concluding a stack is absent.";

/// The one line appended when the walk stopped at its declared depth (#7781).
///
/// Why: round-3 — the depth bound is the design's scope, not a failure, so it
/// gets a plain statement of fact rather than [`TRUNCATED_NOTE`]'s call to
/// treat the list as partial and go research it.
/// What: one sentence naming [`MAX_NESTED_DEPTH`], so the rendered number cannot
/// drift from the walk's own constant.
/// Test: `depth_limited_detection_says_so`.
fn depth_note() -> String {
    format!("\n\nManifests deeper than {MAX_NESTED_DEPTH} directories were not probed.")
}

/// Render the per-project stack-profile section for `project_dir`.
///
/// Why: the PM prompt must state the project's ACTUAL stack rather than inherit a
/// default one. Deriving the section from the same marker detection used for
/// agent scoping keeps the delegation surface and the prose priming consistent,
/// and guarantees an unknown project gets a neutral, detect-first profile instead
/// of any language default (#1971).
/// What: probes `project_dir` for language markers via [`detected_stack_engineers`].
/// When at least one matches, returns a section listing the matching
/// `<lang>-engineer` stems (sorted, de-duplicated by the `BTreeSet`) and pointing
/// the quality gate at the project's own configured checks. When none match,
/// returns a NEUTRAL section that forbids assuming any stack and mandates a
/// Research pass to detect it before routing. Pure and side-effect-free apart
/// from the filesystem `exists()` probes performed by [`detected_stack_engineers`].
/// Either body ends with [`TRUNCATED_NOTE`] when a resource cap truncated
/// detection, and with [`depth_note`] when the walk stopped at its declared
/// depth — independently, so both can appear (#7781).
/// Test: `detected_rust_lists_rust_engineer`, `detected_nextjs_lists_ts_family`,
/// `detected_polyglot_lists_both_families`, `undetected_is_neutral_no_default`,
/// `depth_limited_detection_says_so`.
pub fn stack_profile_section(project_dir: &Path) -> String {
    // #7781: detection is no longer root-only, so neither sentence below says
    // "root" any more. The section is otherwise unchanged — only the engineer
    // set it lists does.
    render_section(detected_stack_engineers(project_dir))
}

/// Render an already-probed [`StackDetection`] as the prompt section.
///
/// Why: split out in #7781 round-3 so the note rules can be tested against a
/// constructed detection. Provoking a RESOURCE cap through the filesystem costs
/// hundreds of directories per assertion, and the end-to-end path is already
/// pinned by `core::manifest::framework_tests`.
/// What: the detected-list body or the neutral body, with [`TRUNCATED_NOTE`]
/// and [`depth_note`] appended independently. Pure.
/// Test: `truncated_detection_says_so`, `depth_limited_detection_says_so`,
/// `both_notes_render_when_both_bounds_trip`.
fn render_section(detection: StackDetection) -> String {
    // #7781 round-3: a resource cap that gave up must say so, or an incomplete
    // list reads as a complete one. The depth bound states its scope instead —
    // one note per meaning, so neither is diluted by the other.
    let mut note = String::new();
    if detection.truncated {
        note.push_str(TRUNCATED_NOTE);
    }
    if detection.depth_limited {
        note.push_str(&depth_note());
    }
    let engineers = detection.engineers;

    if engineers.is_empty() {
        return format!(
            "{STACK_PROFILE_HEADING}\n\n\
             No known language or framework marker files were found in this \
             project. **Do NOT assume any stack** — not Rust, not Python, \
             not Node/TypeScript. Begin with a **MANDATORY Research phase** to \
             detect the stack from the repository before routing any \
             implementation work, then delegate to the matching \
             `<lang>-engineer`. Never fall back to a default stack profile.{note}"
        );
    }

    let list = engineers
        .iter()
        .map(|stem| format!("- `{stem}`"))
        .collect::<Vec<_>>()
        .join("\n");

    format!(
        "{STACK_PROFILE_HEADING}\n\n\
         trusty-mpm probed this project's marker files and detected the \
         stack below. Route hands-on code work to the matching language \
         engineer(s) — prefer the most specific — and never a generic \
         `engineer` when one of these fits:\n\n\
         {list}\n\n\
         **Quality gate:** use THIS project's own configured checks (its \
         `Makefile` target, `package.json` scripts, or CI pipeline) — confirm \
         the real commands before citing them; do not assume `cargo`/`make \
         check` unless the project actually uses them. If a task clearly touches \
         a stack not listed above, run a Research pass to confirm before \
         routing — never fall back to a default stack profile.{note}"
    )
}

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

    /// Create an empty marker file `name` under `dir`, making parents as needed.
    fn touch(dir: &Path, name: &str) {
        let path = dir.join(name);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, "").unwrap();
    }

    /// A Cargo project routes to rust-engineer and cites the project's own gate.
    ///
    /// Why: the core positive case — a detected Rust project must produce a
    /// rust-engineer routing derived from detection, not a hardcoded default.
    /// What: writes `Cargo.toml`, asserts the section names `rust-engineer`, the
    /// heading, and the project-own quality-gate wording; and does NOT contain
    /// the neutral "do NOT assume" block.
    /// Test: this function IS the test.
    #[test]
    fn detected_rust_lists_rust_engineer() {
        let tmp = TempDir::new().unwrap();
        touch(tmp.path(), "Cargo.toml");

        let section = stack_profile_section(tmp.path());
        assert!(section.contains(STACK_PROFILE_HEADING));
        assert!(
            section.contains("`rust-engineer`"),
            "must route to rust-engineer"
        );
        assert!(
            section.contains("own configured checks"),
            "must point at the project's own quality gate"
        );
        assert!(
            !section.contains("Do NOT assume any stack"),
            "a detected project must not emit the neutral block"
        );
    }

    /// A Next.js/TypeScript project lists the JS/TS family engineers.
    ///
    /// Why: this is the exact ai-power-rankings recurrence from #1971 — a
    /// package.json + tsconfig.json + next.config project must route to the
    /// nextjs/typescript engineers, never rust-engineer.
    /// What: writes `package.json`, `tsconfig.json`, `next.config.js`, asserts the
    /// section names `nextjs-engineer` and `typescript-engineer` and does NOT name
    /// `rust-engineer`.
    /// Test: this function IS the test.
    #[test]
    fn detected_nextjs_lists_ts_family() {
        let tmp = TempDir::new().unwrap();
        touch(tmp.path(), "package.json");
        touch(tmp.path(), "tsconfig.json");
        touch(tmp.path(), "next.config.js");

        let section = stack_profile_section(tmp.path());
        assert!(
            section.contains("`nextjs-engineer`"),
            "must route to nextjs-engineer"
        );
        assert!(
            section.contains("`typescript-engineer`"),
            "must route to typescript-engineer"
        );
        assert!(
            !section.contains("`rust-engineer`"),
            "a Next.js project must NOT route to rust-engineer (the #1971 bug)"
        );
    }

    /// A .NET project (`*.csproj` glob marker) routes to dotnet-engineer.
    ///
    /// Why: #2831 — a C#/.NET/VB.NET project must get its specialist rather than
    /// the general-purpose fallback, so the derived stack section must name
    /// `dotnet-engineer` when a project/solution marker is present.
    /// What: writes `App.csproj`, asserts the section names `dotnet-engineer` and
    /// does not emit the neutral "Do NOT assume" block.
    /// Test: this function IS the test.
    #[test]
    fn detected_dotnet_lists_dotnet_engineer() {
        let tmp = TempDir::new().unwrap();
        touch(tmp.path(), "App.csproj");

        let section = stack_profile_section(tmp.path());
        assert!(
            section.contains("`dotnet-engineer`"),
            "must route to dotnet-engineer"
        );
        assert!(
            !section.contains("Do NOT assume any stack"),
            "a detected .NET project must not emit the neutral block"
        );
    }

    /// An unknown project type yields a neutral, detect-first profile.
    ///
    /// Why: Bob's requirement — undetected stack must NEVER default to any stack;
    /// it must mandate detection before routing.
    /// What: a directory with only a README asserts the neutral block: the "Do
    /// NOT assume any stack" and "MANDATORY Research" wording, and the absence of
    /// any `<lang>-engineer` routing list.
    /// Test: this function IS the test.
    #[test]
    fn undetected_is_neutral_no_default() {
        let tmp = TempDir::new().unwrap();
        touch(tmp.path(), "README.md");

        let section = stack_profile_section(tmp.path());
        assert!(section.contains(STACK_PROFILE_HEADING));
        assert!(section.contains("Do NOT assume any stack"));
        assert!(section.contains("MANDATORY Research"));
        assert!(
            !section.contains("rust-engineer") && !section.contains("python-engineer"),
            "the neutral block must not name any language engineer"
        );
    }

    /// A polyglot project (Rust + JS/TS) names both families, dropping neither.
    ///
    /// Why: mirrors `project_lang::polyglot_project_keeps_both` at the rendering
    /// layer — a repo with markers for two ecosystems must route to engineers
    /// from BOTH, not silently pick one, since a wrong exclusive pick is exactly
    /// the class of bug #1971 is about.
    /// What: writes both `Cargo.toml` and `package.json`, asserts the rendered
    /// section names `rust-engineer` AND a JS-family engineer stem
    /// (`javascript-engineer`), and does not emit the neutral "do NOT assume"
    /// block.
    /// Test: this function IS the test.
    #[test]
    fn detected_polyglot_lists_both_families() {
        let tmp = TempDir::new().unwrap();
        touch(tmp.path(), "Cargo.toml");
        touch(tmp.path(), "package.json");

        let section = stack_profile_section(tmp.path());
        assert!(
            section.contains("`rust-engineer`"),
            "polyglot project must still route to rust-engineer"
        );
        assert!(
            section.contains("`javascript-engineer`"),
            "polyglot project must also route to the JS/TS family"
        );
        assert!(
            !section.contains("Do NOT assume any stack"),
            "a detected polyglot project must not emit the neutral block"
        );
    }

    /// A detection for `engineers`, with both scan flags clear.
    fn detection(engineers: &[&str]) -> StackDetection {
        StackDetection {
            engineers: engineers.iter().map(|s| (*s).to_string()).collect(),
            truncated: false,
            depth_limited: false,
        }
    }

    /// A RESOURCE-capped scan is stated in the section, not silently dropped.
    ///
    /// Why (#7781 round-2 HIGH): the PM routes on this section. A list shortened
    /// by a scan bound reads exactly like a complete one, so the PM would
    /// confidently conclude a stack is absent from evidence that never covered
    /// it.
    /// What: renders a detection carrying `truncated` alone; asserts the section
    /// still names `rust-engineer` AND says detection was truncated, and that it
    /// does NOT carry the depth note (round-3: the two notes are separate, and
    /// neither is unconditional). The unflagged counterpart asserts both notes
    /// are absent. The resource cap is provoked for real through the filesystem
    /// in `core::manifest::framework_tests`.
    /// Test: this function IS the test.
    #[test]
    fn truncated_detection_says_so() {
        let section = render_section(StackDetection {
            truncated: true,
            ..detection(&["rust-engineer"])
        });
        assert!(
            section.contains("Detection was truncated"),
            "a resource cap that gave up must say so in the section: {section}"
        );
        assert!(
            section.contains("`rust-engineer`"),
            "truncation qualifies the list; it must not replace it"
        );
        assert!(
            !section.contains("were not probed"),
            "the depth note belongs to the depth bound, not a resource cap"
        );

        let read_in_full = render_section(detection(&["rust-engineer"]));
        assert!(
            !read_in_full.contains("Detection was truncated")
                && !read_in_full.contains("were not probed"),
            "a fully-read tree must carry neither note"
        );
    }

    /// The depth bound renders its own one-line note, never the truncation one.
    ///
    /// Why (#7781 round-3): the depth bound trips on most real repositories, so
    /// rendering it as truncation told the PM to treat almost every stack list
    /// as partial and go research it — advice that is ignored once it is
    /// universal. The depth limit is the design's scope; it states that and
    /// nothing more.
    /// What: end-to-end — a Cargo project with a directory nested past
    /// `MAX_NESTED_DEPTH`; asserts the section names `rust-engineer`, carries the
    /// depth note naming the depth, and does NOT claim truncation.
    /// Test: this function IS the test.
    #[test]
    fn depth_limited_detection_says_so() {
        let deep = TempDir::new().unwrap();
        touch(deep.path(), "Cargo.toml");
        fs::create_dir_all(deep.path().join("a/b/c/d/e")).unwrap();

        let section = stack_profile_section(deep.path());
        assert!(
            section.contains(&format!(
                "Manifests deeper than {MAX_NESTED_DEPTH} directories were not probed."
            )),
            "the depth bound states its scope, naming the depth: {section}"
        );
        assert!(
            !section.contains("Detection was truncated"),
            "the declared depth is not a resource cap and must not read as one"
        );
        assert!(
            section.contains("`rust-engineer`"),
            "the depth note qualifies the list; it must not replace it"
        );
    }

    /// Both notes render when both bounds trip, each stating its own meaning.
    ///
    /// Why (#7781 round-3 review): the notes are appended independently, and
    /// only one-flag renders were pinned — a change that made them exclusive
    /// (an `else if`, or one note overwriting the other) would have passed. A
    /// wide, deep monorepo trips both, and it is the one case where the PM most
    /// needs the truncation warning.
    /// What: renders a detection carrying both flags; asserts the truncation
    /// note, the depth note naming the depth, and the engineer list all appear
    /// together.
    /// Test: this function IS the test.
    #[test]
    fn both_notes_render_when_both_bounds_trip() {
        let section = render_section(StackDetection {
            truncated: true,
            depth_limited: true,
            ..detection(&["rust-engineer"])
        });
        assert!(
            section.contains("Detection was truncated"),
            "the resource cap still says so when the depth bound also tripped: {section}"
        );
        assert!(
            section.contains(&format!(
                "Manifests deeper than {MAX_NESTED_DEPTH} directories were not probed."
            )),
            "the depth note is not suppressed by the truncation note: {section}"
        );
        assert!(
            section.contains("`rust-engineer`"),
            "two notes qualify the list; they must not replace it: {section}"
        );
    }

    /// The heading is present whether or not a stack is detected.
    ///
    /// Why: [`crate::core::instruction_overrides`] and human readers rely on the
    /// heading being present in both modes to locate the block.
    /// What: asserts the heading appears for both a detected (Cargo.toml) and an
    /// undetected (empty) project root.
    /// Test: this function IS the test.
    #[test]
    fn heading_present_in_both_modes() {
        let detected = TempDir::new().unwrap();
        touch(detected.path(), "go.mod");
        assert!(stack_profile_section(detected.path()).contains(STACK_PROFILE_HEADING));

        let undetected = TempDir::new().unwrap();
        assert!(stack_profile_section(undetected.path()).contains(STACK_PROFILE_HEADING));
    }
}