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
//! Unit tests for [`super::ensure_project_skill_tier`] — the #4880 redeploy
//! trigger.
//!
//! Why a sibling file: `project_skill_tier.rs` is a PRODUCTION file under this
//! repo's 500-SLOC cap; a `*_tests.rs` sibling is classified as a test file
//! (3000-SLOC cap) by `scripts/check_line_cap.sh`, matching
//! `managed_config_tests.rs` and `agent_source_tests.rs`.
//!
//! What: the two triggers (a version bump, a skill-selection change), the no-op
//! when neither moved, the manifest edit that deliberately does NOT trigger, and
//! the two negatives the owner's standing rule demands — a project-custom skill
//! and a checksum-frozen hand edit both survive a version-triggered redeploy.

use super::*;
use crate::core::skill_tiers::{SkillTier, list_project_custom_stems};
use tempfile::TempDir;

/// Two versions standing in for "before" and "after" an upgrade.
const V1: &str = "1.3.5";
const V2: &str = "1.3.6";

/// A framework layout whose bundled skill source holds one seeded skill.
///
/// Why: `FrameworkPaths::under` keeps every path inside the temp dir, so these
/// tests never touch the real `~/.trusty-mpm` or `~/.claude`.
/// What: creates `fw.skills` and writes `<stem>.md` with `body`.
fn seed_framework(base: &Path, stem: &str, body: &str) -> FrameworkPaths {
    let fw = FrameworkPaths::under(base);
    std::fs::create_dir_all(&fw.skills).unwrap();
    std::fs::write(fw.skills.join(format!("{stem}.md")), body).unwrap();
    fw
}

/// Overwrite the USER-CUSTOM source copy of a seeded skill.
///
/// #6586 retargeted this from the bundled source. Bundled skills are user-tier
/// only now, so a bundled stem never reaches this destination and could no
/// longer carry the stamp-trigger, freeze and shadowing behaviour these tests
/// are actually about. The user-custom tier still deploys here, so it is the
/// vehicle; every subject below is unchanged.
fn reseed(fw: &FrameworkPaths, stem: &str, body: &str) {
    std::fs::write(fw.user_skill_source_dir().join(format!("{stem}.md")), body).unwrap();
}

/// Write a project-override manifest layer for `project_dir`.
///
/// Why: this is the operator-editable layer `ManifestSources::resolve` reads at
/// the highest precedence (#4832 moved it into `framework/`), and the reason the
/// stamp still carries a manifest component at all — it can move between
/// releases.
/// What: writes `<harness-root>/.trusty-mpm/framework/manifest.toml`.
fn write_project_manifest(project_dir: &Path, body: &str) {
    let dir = crate::core::harness_root::framework_dir(project_dir);
    std::fs::create_dir_all(&dir).unwrap();
    std::fs::write(dir.join("manifest.toml"), body).unwrap();
}

/// The resolved plan for a project, as [`ensure_project_skill_tier`] sees it.
fn plan_for(fw: &FrameworkPaths, project_dir: &Path) -> HarnessPlan {
    let catalog_root = crate::content::catalog_root_for(&fw.root);
    let manifest = resolve_manifest(&ManifestSources::resolve(project_dir, &catalog_root));
    HarnessPlan::from_manifest(&manifest, fw, &catalog_root)
}

/// A temp base, a framework holding one seeded skill, and an empty workspace.
fn fixture(stem: &str, body: &str) -> (TempDir, FrameworkPaths, std::path::PathBuf) {
    let tmp = TempDir::new().unwrap();
    let fw = seed_framework(tmp.path(), stem, body);
    // #6586: the same stem in the USER-CUSTOM source, which is what actually
    // deploys to the project tier now — see `reseed`. The bundled copy above
    // stays so the missing/empty-source guards still have a source to find.
    std::fs::create_dir_all(fw.user_skill_source_dir()).unwrap();
    reseed(&fw, stem, body);
    let project = tmp.path().join("workspace");
    std::fs::create_dir_all(&project).unwrap();
    (tmp, fw, project)
}

#[test]
fn stamp_is_stable_across_calls() {
    let (_tmp, fw, project) = fixture("probe-skill", "V1");
    let plan = plan_for(&fw, &project);
    assert_eq!(
        project_tier_stamp(V1, &plan),
        project_tier_stamp(V1, &plan),
        "the stamp is a pure function of its inputs"
    );
}

#[test]
fn stamp_changes_with_the_version() {
    let (_tmp, fw, project) = fixture("probe-skill", "V1");
    let plan = plan_for(&fw, &project);
    assert_ne!(
        project_tier_stamp(V1, &plan),
        project_tier_stamp(V2, &plan),
        "a version bump is the primary trigger"
    );
}

#[test]
fn stamp_changes_with_the_skill_selection() {
    let (_tmp, fw, project) = fixture("probe-skill", "V1");
    let before = project_tier_stamp(V1, &plan_for(&fw, &project));

    write_project_manifest(&project, "[skills]\nexclude = [\"something-else\"]\n");
    let after = project_tier_stamp(V1, &plan_for(&fw, &project));

    assert_ne!(
        before, after,
        "an operator editing `[skills]` in the project override must trigger, \
         without waiting for a release"
    );
}

/// The stamp carries the skill SELECTION, not the whole manifest.
///
/// Why: a `[style]` or `[mcp]` edit cannot change which skills this tier should
/// hold, so triggering on it would be a redeploy with nothing to deploy. This is
/// what "keep the manifest identity only if it alters the skill set" means
/// concretely.
#[test]
fn stamp_ignores_manifest_changes_outside_the_skill_selection() {
    let (_tmp, fw, project) = fixture("probe-skill", "V1");
    let before = project_tier_stamp(V1, &plan_for(&fw, &project));

    write_project_manifest(&project, "[style]\nactive = \"probe-style\"\n");
    let after = project_tier_stamp(V1, &plan_for(&fw, &project));

    assert_eq!(
        before, after,
        "a style edit changes no skill, so it must not force a redeploy"
    );
}

/// Trigger 1: a version bump refreshes a stale MANAGED project-tier skill.
///
/// Why: this is the whole defect. `<project>/.claude/skills` outranks the user
/// tier `managed_config` refreshes every run, so a project copy left behind by
/// an older binary silently shadowed the current one — the #4408 shape. The
/// owner's model: "when version is updated, we re-run deployment."
#[test]
fn version_bump_redeploys() {
    let (_tmp, fw, project) = fixture("probe-skill", "V1");
    let deployed = project_skill_dir(&project)
        .join("probe-skill")
        .join("SKILL.md");

    let first = ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();
    assert!(first.deployed, "the first run has no stamp, so it deploys");
    assert_eq!(std::fs::read_to_string(&deployed).unwrap(), "V1");

    // The upgrade: newer skill text shipped under a newer version.
    reseed(&fw, "probe-skill", "V2-REFRESHED");
    let second = ensure_project_skill_tier_for_version(&fw, &project, V2).unwrap();

    assert!(second.deployed, "a version bump must re-run deployment");
    assert_eq!(
        std::fs::read_to_string(&deployed).unwrap(),
        "V2-REFRESHED",
        "a managed, user-unmodified project-tier skill must be refreshed"
    );
}

/// Trigger 2: a skill-selection change redeploys at the SAME version.
///
/// Why: the operator's `<harness-root>/.trusty-mpm/framework/manifest.toml` is
/// editable between releases and feeds `plan.skill_include`/`skill_exclude`
/// directly (`manifest::apply`). Waiting for a version bump would strand that
/// edit.
#[test]
fn skill_selection_change_redeploys() {
    let (_tmp, fw, project) = fixture("probe-skill", "V1");
    let deployed = project_skill_dir(&project)
        .join("probe-skill")
        .join("SKILL.md");

    ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();

    reseed(&fw, "probe-skill", "V1-PLUS");
    // Excludes a DIFFERENT stem, so `probe-skill` stays selected — this isolates
    // "the selection moved" from "the selection now drops this skill".
    write_project_manifest(&project, "[skills]\nexclude = [\"something-else\"]\n");
    let report = ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();

    assert!(
        report.deployed,
        "an edited `[skills]` selection must deploy at the same version"
    );
    assert_eq!(std::fs::read_to_string(&deployed).unwrap(), "V1-PLUS");
}

/// The no-op: same version, same selection, nothing written.
///
/// Why mtime and not just `deployed == false`: "not on every run" is the point
/// of the ruling, and an unconditional redeploy would still leave the content
/// correct while churning every file in the workspace on every resume. This
/// mirrors `skill_source::ensure_skill_source_fresh_is_noop_when_current`.
#[test]
fn unchanged_version_is_a_noop() {
    let (_tmp, fw, project) = fixture("probe-skill", "V1");
    let deployed = project_skill_dir(&project)
        .join("probe-skill")
        .join("SKILL.md");

    ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();
    let before = std::fs::metadata(&deployed).unwrap().modified().unwrap();

    let second = ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();

    assert!(
        !second.deployed,
        "a matching stamp must take the no-op path, not a redeploy"
    );
    assert_eq!(second.stats, DeployStats::default());
    let after = std::fs::metadata(&deployed).unwrap().modified().unwrap();
    assert_eq!(before, after, "a no-op must not rewrite a single file");
}

/// Negative 1: a project-custom skill survives a version-triggered redeploy.
///
/// Why: clause 3 of the owner's model — local customizations are tracked in the
/// local manifest and outlive an upgrade. "Re-run deployment on version change"
/// must not become "wipe the user's local copies on upgrade". Two independent
/// mechanisms enforce it and this asserts the stronger one:
/// `tiers::list_project_custom_stems` sees an unmanaged directory, so
/// `plan_skill_tiers` records a Project-over-Bundled shadow and drops the stem
/// from `bundled_deploy` — it never reaches file I/O, which is why it appears in
/// `shadowed` and in NO `stats` vector. `deployer::deploy_one_file`'s
/// unmanaged-target skip is the second line of defense behind it.
#[test]
fn project_custom_skill_is_never_overwritten() {
    let (_tmp, fw, project) = fixture("probe-skill", "BUNDLED");

    // The operator's own file, sharing a stem with a bundled skill so the
    // ownership rule is what is under test rather than mere absence.
    let custom = project_skill_dir(&project)
        .join("probe-skill")
        .join("SKILL.md");
    std::fs::create_dir_all(custom.parent().unwrap()).unwrap();
    let mine = "MY OWN SKILL — never overwrite this\n";
    std::fs::write(&custom, mine).unwrap();

    let first = ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();
    assert_eq!(std::fs::read_to_string(&custom).unwrap(), mine);
    assert!(
        // #6586: the loser is the USER tier now — a bundled stem is dropped
        // before the planner ever sees it, so it records no shadow at all.
        first.shadowed.iter().any(|s| s.stem == "probe-skill"
            && s.winner == SkillTier::Project
            && s.loser == SkillTier::User),
        "the hand-placed copy must win precedence outright: {:?}",
        first.shadowed
    );
    assert!(
        !first.stats.deployed.iter().any(|s| s == "probe-skill"),
        "a shadowed stem must never be written: {:?}",
        first.stats
    );

    // …and again across the upgrade that re-runs deployment.
    reseed(&fw, "probe-skill", "BUNDLED-V2");
    let second = ensure_project_skill_tier_for_version(&fw, &project, V2).unwrap();

    assert!(second.deployed, "the version bumped, so a deploy ran");
    assert_eq!(
        std::fs::read_to_string(&custom).unwrap(),
        mine,
        "a version bump must not wipe the operator's local copy"
    );
    assert!(
        second.shadowed.iter().any(|s| s.stem == "probe-skill"),
        "…still shadowed on the version-triggered run: {:?}",
        second.shadowed
    );
}

/// Negative 2: a checksum-frozen skill survives a version-triggered redeploy.
///
/// Why: a skill tm DID deploy (so it is in the manifest) but the operator has
/// since hand-edited drifts from its recorded checksum. `deploy_one_file`
/// preserves it. Reconciling today's already-frozen files is a separate one-time
/// migration, not this path's job.
#[test]
fn frozen_skill_is_still_skipped() {
    let (_tmp, fw, project) = fixture("probe-skill", "V1");
    let deployed = project_skill_dir(&project)
        .join("probe-skill")
        .join("SKILL.md");

    // First run makes it MANAGED, then the operator edits it in place.
    ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();
    let hand_edit = "V1 plus my own notes\n";
    std::fs::write(&deployed, hand_edit).unwrap();

    reseed(&fw, "probe-skill", "V2");
    let report = ensure_project_skill_tier_for_version(&fw, &project, V2).unwrap();

    assert!(report.deployed, "the version bumped, so a deploy ran");
    assert_eq!(
        std::fs::read_to_string(&deployed).unwrap(),
        hand_edit,
        "a checksum-frozen hand edit must survive a version-triggered redeploy"
    );
    assert!(
        report.stats.skipped.iter().any(|s| s == "probe-skill"),
        "the frozen skill must be reported as skipped: {:?}",
        report.stats
    );
}

#[test]
fn missing_skill_source_does_not_stamp() {
    let tmp = TempDir::new().unwrap();
    let mut fw = FrameworkPaths::under(tmp.path());
    fw.trusty_mpm_root = None;
    let project = tmp.path().join("workspace");
    std::fs::create_dir_all(&project).unwrap();

    let report = ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();

    assert!(!report.deployed);
    assert!(
        !project_skill_dir(&project)
            .join(PROJECT_TIER_STAMP_FILE)
            .exists(),
        "an empty deploy must not be recorded as current — the next run retries"
    );
}

#[test]
fn deploys_into_the_workspace_not_the_framework_home() {
    let (_tmp, fw, project) = fixture("probe-skill", "V1");

    ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();

    assert!(
        project_skill_dir(&project)
            .join("probe-skill")
            .join("SKILL.md")
            .is_file()
    );
    assert!(
        !fw.claude_skills_dir().join("probe-skill").exists(),
        "a home-scoped `fw` must not redirect this deploy into ~/.claude/skills"
    );
}

#[test]
fn stamp_file_is_ignored_by_the_tier_planner() {
    let (_tmp, fw, project) = fixture("probe-skill", "V1");

    ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();
    let dest = project_skill_dir(&project);
    assert!(dest.join(PROJECT_TIER_STAMP_FILE).is_file());

    let custom = list_project_custom_stems(&dest).unwrap();
    assert!(
        !custom.iter().any(|s| s.starts_with('.')),
        "the stamp marker must never be mistaken for a project-custom skill: {custom:?}"
    );
}

/// PR #4882 review (MEDIUM): an EXISTING but empty source must refuse, not
/// stamp.
///
/// Why: `skill_source_dir()` prefers the `agents/skills` submodule whenever that
/// path exists, and an unfetched or shallow submodule is an existing-but-empty
/// directory. An `is_dir()`-only guard passes there, writes the stamp as though
/// deployment succeeded, and leaves the project tier stale forever with no
/// signal — the silent-staleness class of #4840 / #4873.
#[test]
fn empty_skill_source_does_not_stamp() {
    let tmp = TempDir::new().unwrap();
    let submodule_root = TempDir::new().unwrap();
    let submodule_skills = submodule_root.path().join("agents").join("skills");
    std::fs::create_dir_all(&submodule_skills).unwrap();

    let mut fw = FrameworkPaths::under(tmp.path());
    fw.trusty_mpm_root = Some(submodule_root.path().to_path_buf());
    assert_eq!(
        fw.skill_source_dir(),
        submodule_skills,
        "fixture guard: the empty submodule must be the resolved source"
    );

    let project = tmp.path().join("workspace");
    std::fs::create_dir_all(&project).unwrap();

    let report = ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();

    assert!(!report.deployed);
    assert!(
        !project_skill_dir(&project)
            .join(PROJECT_TIER_STAMP_FILE)
            .exists(),
        "an empty source must not be recorded as a successful deploy — the stamp \
         would freeze the project tier stale forever"
    );
}

// ── bundled skills are user-tier only (#6586) ────────────────────────────

/// The issue's first acceptance: a fresh provision writes NO bundled skill into
/// the project's own tier (#6586).
///
/// Owner ruling 2026-09-01, the same principle #4448 settled for bundled agents.
/// Before it, all 21 bundled `tm-*` skills existed byte-identically in both
/// tiers, so an upgrade had two copies to keep in step and a drifted project
/// copy was indistinguishable from a deliberate customization.
///
/// Fails before #6586: `probe-bundled/SKILL.md` is written into the project tier
/// and `deployed` names it.
#[test]
fn project_tier_receives_no_bundled_skill() {
    let tmp = TempDir::new().unwrap();
    let fw = seed_framework(tmp.path(), "probe-bundled", "BUNDLED ONLY");
    let project = tmp.path().join("workspace");
    std::fs::create_dir_all(&project).unwrap();

    let report = ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();

    assert!(
        !report.stats.deployed.iter().any(|s| s == "probe-bundled"),
        "a bundled skill must not be deployed to the project tier: {:?}",
        report.stats
    );
    assert!(
        !project_skill_dir(&project).join("probe-bundled").exists(),
        "and nothing may be written for it on disk"
    );
}

/// The complement: the user-custom tier still deploys here, so the project tier
/// is narrowed rather than retired (#6586).
///
/// Without this, setting the bundled selection to nothing could have been
/// achieved by disabling the whole deploy, and no test would notice.
#[test]
fn project_tier_still_receives_a_user_custom_skill() {
    let (_tmp, fw, project) = fixture("probe-skill", "V1");

    let report = ensure_project_skill_tier_for_version(&fw, &project, V1).unwrap();

    assert!(report.deployed, "the deploy must still run: {report:?}");
    assert_eq!(
        std::fs::read_to_string(
            project_skill_dir(&project)
                .join("probe-skill")
                .join("SKILL.md")
        )
        .unwrap(),
        "V1",
        "a user-custom skill still reaches the project tier"
    );
}

/// The bundled skill the project tier now declines is on disk one tier up
/// (#6586) — which is why declining it costs no coverage.
///
/// `managed_config` deploys the bundled roster to the user tier with `|_| true`,
/// the WHOLE roster rather than the manifest-selected subset, and for skills
/// Claude Code resolves personal above project. This asserts the planner half of
/// that claim: the user destination still takes every bundled stem.
#[test]
fn bundled_skill_reaches_the_user_tier_only() {
    use crate::core::skill_tiers::plan_skill_tiers;
    use std::collections::BTreeSet;

    let bundled: BTreeSet<String> = ["tm-ticketing".to_string()].into_iter().collect();
    let empty = BTreeSet::new();

    let user_dest = plan_skill_tiers(&empty, &empty, &bundled);
    assert!(
        user_dest.bundled_deploy.contains("tm-ticketing"),
        "the user tier deploys the whole bundled roster: {user_dest:?}"
    );

    let project_dest: BTreeSet<String> = bundled
        .iter()
        .filter(|s| super::bundled_excluded_from_project_tier(s))
        .cloned()
        .collect();
    assert!(
        project_dest.is_empty(),
        "and the project tier's selection admits none of it: {project_dest:?}"
    );
}