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
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
//! Re-sync a live managed session's deployed assets against the current
//! catalog (issue #2444).
//!
//! Why: [`super::prepare_session_inner`] deploys agents, skills, and output
//! styles exactly ONCE, at launch time (DOC-34/#2002). `tm sessions
//! sync-assets` needs a pure, OFFLINE re-run of that same roster+style deploy
//! step — no catalog git sync (that is `tm catalog sync`'s job,
//! [`crate::content::CatalogSync`]) — targeted at an EXISTING session's
//! workspace, so a mid-session catalog/bundled-asset fix reaches a long-lived
//! session without a full relaunch. This module deliberately reuses the exact
//! deployers `prepare_session_inner` and [`crate::core::update_check::apply_catalog`]
//! already call (`deploy_agents_filtered`, `deploy_all_skill_tiers`,
//! `deploy_output_style`) rather than inventing a third redeploy path.
//! What: [`sync_session_assets`] resolves the harness manifest/plan for
//! `project_dir` exactly as launch does, redeploys the manifest-selected agents,
//! sweeps the project tier's stray bundled skill copies (#6754, the same
//! `skills::sweep_project_tier_strays` session start runs),
//! redeploys skills to the two destinations #6586 split them across (the
//! bundled roster to the managed user tier, the user-custom tier to the
//! project), and refreshes
//! the project-tier output styles. It intentionally does NOT touch CLAUDE.md,
//! hooks, or MCP injection — those are session-identity concerns outside
//! asset-catalog sync, and re-running them risks clobbering session-local state
//! (e.g. the trusty-memory palace pin) sync-assets has no business touching.
//! Overwrite semantics match launch: a MANAGED file (recorded in the checksum
//! manifest) whose on-disk content still matches what tm wrote is refreshed; a
//! user-modified or user-owned file is left alone (the deployers' own
//! ownership rule — see `agent_deployer`/`skill_deployer`/`skill_tiers`).
//! Test: `sync_assets_redeploys_new_agent`, `sync_assets_refreshes_stale_skill`,
//! `sync_assets_refreshes_project_output_style`.

use std::path::Path;

use super::settings::deploy_output_style;
use crate::core::agent_deployer::{DeployResult, deploy_agents_filtered, retract_framework_agents};
use crate::core::paths::FrameworkPaths;
use crate::core::skill_deployer::DeployStats;
use crate::core::skill_tiers::deploy_all_skill_tiers;

/// A failure raised while re-syncing a session's deployed assets.
///
/// Why: the daemon route and the `tm sessions sync-assets` CLI need a typed
/// surface naming which stage failed, mirroring [`crate::core::update_check::apply::ApplyError`](crate::core::update_check::ApplyError)(crate::core::update_check::ApplyError)'s
/// shape (minus the `Sync`/`Prune` variants, which do not apply here).
/// What: variants for the agent-deploy and skill-deploy stages.
/// Test: surfaced indirectly — the happy path is covered by this module's
/// `sync_assets_*` tests; deploy-failure mapping is exercised the same way
/// `apply.rs`'s own error variants are (no dedicated failure-injection test,
/// since the underlying deployer error paths are already covered there).
#[derive(Debug, thiserror::Error)]
pub enum SyncAssetsError {
    /// Redeploying agents failed.
    #[error("agent redeploy failed: {0}")]
    AgentDeploy(String),
    /// Redeploying skills failed.
    #[error("skill redeploy failed: {0}")]
    SkillDeploy(String),
}

/// Summary of one [`sync_session_assets`] run.
///
/// Why: the daemon route and CLI both need the deployed/skipped counts to
/// render an honest "N agents refreshed, M skipped (user-modified)" report.
/// What: mirrors [`crate::core::update_check::apply::ApplyReport`](crate::core::update_check::ApplyReport)(crate::core::update_check::ApplyReport)'s
/// agent/skill fields (no `fetched`/prune fields — sync-assets never syncs the
/// catalog checkout or prunes deselected files).
/// Test: `sync_assets_redeploys_new_agent`, `sync_assets_refreshes_stale_skill`.
#[derive(Debug, Default)]
pub struct SyncAssetsReport {
    /// Agent filenames (re)written this run.
    pub agents_deployed: Vec<String>,
    /// Agent filenames left as-is (user-modified / unchanged).
    pub agents_skipped: Vec<String>,
    /// Skill stems (re)written this run.
    pub skills_deployed: Vec<String>,
    /// Skill stems left as-is.
    pub skills_skipped: Vec<String>,
    /// Whether the project-tier output styles were refreshed successfully.
    ///
    /// `false` only on a write failure (disk full, permissions); non-fatal —
    /// the agent/skill sync above still counts as having run.
    pub output_style_synced: bool,
    /// Why the #4409 workspace retraction could not run, if it failed.
    ///
    /// Why: a failed retraction means this workspace's `.claude/agents/` may
    /// still be SHADOWING the canonical config-dir roster — the one condition
    /// an operator running sync-assets needs told about. `prepare_session`
    /// reports the same failure through `PrepReport::roster_errors`; leaving it
    /// as a bare log line here made the two paths disagree about whether the
    /// signal is worth surfacing.
    /// What: `None` on success (including the common "nothing to retract"
    /// no-op); `Some(detail)` when [`retract_framework_agents`] returned `Err`
    /// — e.g. a corrupt ownership ledger, which it refuses to act on.
    /// Test: `sync_assets_reports_retraction_failure_on_corrupt_manifest`.
    pub retraction_error: Option<String>,
    /// What the #4448 shadowing-agent quarantine did, if anything.
    ///
    /// Why: this sweep RENAMES files in the operator's working tree. Reporting
    /// a bare count would be worse than silence — the operator needs the
    /// receipt path to see what moved and how to put it back. `None` on a clean
    /// sweep so a project with nothing to fix reports nothing.
    /// What: [`super::quarantine_shadows::summarize`]'s line on a sweep that
    /// moved or failed on something; a refusal message when the sweep declined
    /// to run at all (corrupt ledger, empty roster).
    /// Test: `sync_assets_quarantines_a_shadowing_workspace_agent`,
    /// `sync_assets_is_silent_when_nothing_shadows`.
    pub quarantine_summary: Option<String>,
}

/// Re-run the roster + output-style deploy against an EXISTING session's
/// workspace, without touching CLAUDE.md, hooks, or MCP injection.
///
/// Why: this is the concrete redeploy `tm sessions sync-assets` offers,
/// answering issue #2444 — a long-lived session's deployed `.claude/{agents,
/// skills}` otherwise never re-syncs once the bundled/catalog source moves.
/// What: resolves the harness manifest + [`crate::core::manifest::HarnessPlan`]
/// for `project_dir` exactly as [`super::prepare_session_inner`] does, then:
/// (1) redeploys agents via [`deploy_agents_filtered`] against
/// `fw.agent_deploy_dir()` — the tm-managed config-dir tier, issue #4409 — and
/// retracts any bundled copy an older binary left in the workspace's own
/// `.claude/agents/`; (2) redeploys skills via [`deploy_all_skill_tiers`] twice
/// — the bundled roster into [`FrameworkPaths::skill_deploy_dir`] and the
/// user-custom tier into the project's own `.claude/skills/` (#6586); (3)
/// best-effort refreshes the
/// PROJECT-tier output styles via [`deploy_output_style`] (issue #2125 item
/// 2 — the tier a managed session's `--setting-sources project,local` launch
/// actually reads). `fw` MUST be workspace-scoped
/// (`FrameworkPaths::for_managed_workspace(project_dir)`) so the deploy
/// destinations resolve under `project_dir/.claude/…` rather than the real
/// home; the bundled/catalog SOURCE paths still resolve from the shared
/// framework install regardless.
/// Test: `sync_assets_redeploys_new_agent`, `sync_assets_refreshes_stale_skill`,
/// `sync_assets_refreshes_project_output_style`,
/// `sync_assets_never_touches_hand_placed_agent`,
/// `sync_assets_repairs_drifted_bundled_agent`,
/// `sync_assets_retracts_workspace_bundled_agents`.
pub fn sync_session_assets(
    fw: &FrameworkPaths,
    project_dir: &Path,
) -> Result<SyncAssetsReport, SyncAssetsError> {
    // #7806: a sync is a top-level operation, so it opens its own
    // stack-detection scope rather than inheriting the last launch's.
    crate::core::manifest::invalidate_stack_detection(project_dir);
    let catalog_root = crate::content::catalog_root_for(&fw.root);
    let sources = crate::core::manifest::ManifestSources::resolve(project_dir, &catalog_root);
    let manifest = crate::core::manifest::resolve_manifest(&sources);
    let plan = crate::core::manifest::HarnessPlan::from_manifest(&manifest, fw, &catalog_root);

    // #4409: agents refresh in the tm-managed config-dir tier, never
    // per-workspace, and any bundled copy an older binary left in this
    // workspace's `.claude/agents/` is retracted so it stops shadowing the
    // canonical roster. Retraction is non-fatal: a workspace that cannot be
    // cleaned must not block the asset refresh itself.
    let deploy: DeployResult =
        // #7727: agent bodies point at the bundled skills tier.
        deploy_agents_filtered(
            &plan.agent_source,
            &fw.agent_deploy_dir(),
            &fw.skill_deploy_dir(),
            |name| plan.agent_selected(name),
        )
        .map_err(|e| SyncAssetsError::AgentDeploy(e.to_string()))?;

    // Targets `project_dir`'s own `.claude/agents`, not `fw.claude_agents_dir()`
    // — see the identical note in `prepare_session_inner`. Retraction is a
    // WORKSPACE operation, and a home-tier `fw` would otherwise aim it at the
    // operator's `~/.claude/agents`.
    let retraction_error =
        match retract_framework_agents(&project_dir.join(".claude").join("agents")) {
            Ok(_) => None,
            Err(err) => {
                // Surfaced on the report, not just logged: whether a workspace is
                // still shadowing the canonical roster is the one signal an
                // operator running sync-assets actually needs, and the
                // `prepare_session` path already reports it via `roster_errors`.
                tracing::warn!(
                    project_dir = %project_dir.display(),
                    "workspace bundled-agent retraction failed during sync-assets: {err}"
                );
                Some(err.to_string())
            }
        };

    // #4448: retraction's blind spot — an untracked copy written before the
    // ownership ledger existed. Same wiring `prepare_session_inner` uses (one
    // entry point, so the two paths cannot aim differently), same
    // after-retraction ordering, same four-gate refusal set. Never deletes.
    // Test: `sync_assets_quarantines_a_shadowing_workspace_agent`.
    let quarantine_summary =
        match super::quarantine_shadows::quarantine_workspace_shadows(fw, project_dir) {
            Ok(report) => super::quarantine_shadows::summarize(&report),
            // #9011 D4: no content is one line here and at most one ERROR in
            // this process's log, never a WARN per consumer.
            Err(super::quarantine_shadows::ShadowQuarantineError::Roster(err))
                if trusty_agents_common::agent_content::report_not_installed(
                    &err,
                    "agent quarantine skipped during sync-assets",
                ) =>
            {
                Some(err.to_string())
            }
            Err(err) => {
                tracing::warn!(
                    project_dir = %project_dir.display(),
                    "shadowing-agent quarantine refused to run during sync-assets: {err}"
                );
                Some(format!("agent quarantine refused: {err}"))
            }
        };

    // #6754: the same automatic stray sweep session start runs, before the
    // redeploys, so a long-lived session reaches a clean project tier without a
    // relaunch. Non-fatal and self-reporting — see `skills::sweep_project_tier_strays`.
    super::skills::sweep_project_tier_strays(fw, project_dir);

    // #6586: bundled skills refresh in the managed user tier, the same
    // destination `session_launch::skills` and `managed_config` write. Without
    // this call a sync-assets run refreshed no bundled skill anywhere — #2444's
    // whole point — because the project tier declines them below.
    let mut skill_deploy: DeployStats = deploy_all_skill_tiers(
        &plan.skill_source,
        &fw.user_skill_source_dir(),
        &fw.skill_deploy_dir(),
        |_| true,
    )
    .map_err(|e| SyncAssetsError::SkillDeploy(e.to_string()))?
    .stats;

    // #6586: bundled skills are user-tier only — see
    // `project_skill_tier::bundled_excluded_from_project_tier`.
    let project_skills: DeployStats = deploy_all_skill_tiers(
        &plan.skill_source,
        &fw.user_skill_source_dir(),
        &fw.claude_skills_dir(),
        crate::core::project_skill_tier::bundled_excluded_from_project_tier,
    )
    .map_err(|e| SyncAssetsError::SkillDeploy(e.to_string()))?
    .stats;
    skill_deploy.deployed.extend(project_skills.deployed);
    skill_deploy.skipped.extend(project_skills.skipped);
    skill_deploy.unchanged.extend(project_skills.unchanged);

    // Non-fatal, mirroring `prepare_session_inner`'s own treatment of this step.
    // #9012: the style bodies are runtime content; with none nothing is synced.
    let output_style_synced = match crate::core::content_source::framework_content_for(project_dir)
    {
        Ok(content) => deploy_output_style(&content, project_dir).is_ok(),
        Err(err) => {
            tracing::warn!("output styles not synced: {err}");
            false
        }
    };

    Ok(SyncAssetsReport {
        agents_deployed: deploy.deployed,
        agents_skipped: deploy.skipped,
        skills_deployed: skill_deploy.deployed,
        skills_skipped: skill_deploy.skipped,
        output_style_synced,
        retraction_error,
        quarantine_summary,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sync_assets_redeploys_new_agent() {
        // An agent added to the bundled source AFTER a session's original
        // deploy must appear in the workspace on the next sync-assets run.
        let tmp = tempfile::tempdir().unwrap();
        let mut fw = FrameworkPaths::under(tmp.path().join("home"));
        fw.trusty_mpm_root = None;
        let bundled = fw.agent_source_dir();
        std::fs::create_dir_all(&bundled).unwrap();
        std::fs::write(
            bundled.join("rust-engineer.md"),
            "---\nname: rust-engineer\n---\nv1",
        )
        .unwrap();

        let project_dir = tmp.path().join("workspace");
        std::fs::create_dir_all(&project_dir).unwrap();
        let mut ws_fw = fw.clone();
        ws_fw.claude_agents = project_dir.join(".claude").join("agents");
        ws_fw.claude_skills = project_dir.join(".claude").join("skills");

        let first = sync_session_assets(&ws_fw, &project_dir).unwrap();
        assert_eq!(first.agents_deployed, vec!["rust-engineer.md".to_string()]);
        assert!(!ws_fw.agent_deploy_dir().join("qa.md").exists());

        // Catalog gains a new agent mid-session.
        std::fs::write(bundled.join("qa.md"), "---\nname: qa\n---\nv1").unwrap();
        let second = sync_session_assets(&ws_fw, &project_dir).unwrap();
        assert!(second.agents_deployed.contains(&"qa.md".to_string()));
        assert!(ws_fw.agent_deploy_dir().join("qa.md").exists());
    }

    #[test]
    fn sync_assets_refreshes_stale_skill() {
        // The exact #2444 scenario for skills: a bundled skill bumped after the
        // session's original deploy must be refreshed on disk.
        let tmp = tempfile::tempdir().unwrap();
        let mut fw = FrameworkPaths::under(tmp.path().join("home"));
        fw.trusty_mpm_root = None;
        let bundled = fw.skill_source_dir();
        std::fs::create_dir_all(&bundled).unwrap();
        std::fs::write(bundled.join("tm-doctor.md"), "v1.0.0").unwrap();

        let project_dir = tmp.path().join("workspace");
        std::fs::create_dir_all(&project_dir).unwrap();
        let mut ws_fw = fw.clone();
        ws_fw.claude_agents = project_dir.join(".claude").join("agents");
        ws_fw.claude_skills = project_dir.join(".claude").join("skills");

        sync_session_assets(&ws_fw, &project_dir).unwrap();
        // #6586: the bundled copy lives in the managed user tier now, so that is
        // where the refresh has to land. The project tier must stay empty of it.
        let deployed_path = ws_fw.skill_deploy_dir().join("tm-doctor").join("SKILL.md");
        assert_eq!(std::fs::read_to_string(&deployed_path).unwrap(), "v1.0.0");
        assert!(
            !ws_fw.claude_skills_dir().join("tm-doctor").exists(),
            "a bundled skill must never be deployed to the project tier"
        );

        // The catalog/bundled skill bumps version mid-session.
        std::fs::write(bundled.join("tm-doctor.md"), "v1.1.0").unwrap();
        let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
        assert!(report.skills_deployed.contains(&"tm-doctor".to_string()));
        assert_eq!(std::fs::read_to_string(&deployed_path).unwrap(), "v1.1.0");
        assert!(
            !ws_fw.claude_skills_dir().join("tm-doctor").exists(),
            "a bundled skill must never be deployed to the project tier"
        );
    }

    #[test]
    fn sync_assets_refreshes_project_output_style() {
        let tmp = tempfile::tempdir().unwrap();
        let mut fw = FrameworkPaths::under(tmp.path().join("home"));
        fw.trusty_mpm_root = None;

        let project_dir = tmp.path().join("workspace");
        std::fs::create_dir_all(&project_dir).unwrap();
        let mut ws_fw = fw.clone();
        ws_fw.claude_agents = project_dir.join(".claude").join("agents");
        ws_fw.claude_skills = project_dir.join(".claude").join("skills");

        let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
        assert!(report.output_style_synced);
        assert!(
            project_dir
                .join(".claude")
                .join("output-styles")
                .join("trusty-mpm.md")
                .exists()
        );
    }

    #[test]
    fn sync_assets_never_touches_hand_placed_agent() {
        // A hand-placed agent — a file the operator dropped into
        // `.claude/agents/` that trusty-mpm never deployed, so it is absent
        // from the deploy manifest — must survive session-asset sync
        // byte-identical, even when it shares a bundled agent's filename.
        let tmp = tempfile::tempdir().unwrap();
        let mut fw = FrameworkPaths::under(tmp.path().join("home"));
        fw.trusty_mpm_root = None;
        let bundled = fw.agent_source_dir();
        std::fs::create_dir_all(&bundled).unwrap();
        std::fs::write(
            bundled.join("rust-engineer.md"),
            "---\nname: rust-engineer\n---\nv1",
        )
        .unwrap();

        let project_dir = tmp.path().join("workspace");
        std::fs::create_dir_all(&project_dir).unwrap();
        let mut ws_fw = fw.clone();
        ws_fw.claude_agents = project_dir.join(".claude").join("agents");
        ws_fw.claude_skills = project_dir.join(".claude").join("skills");

        // The operator's own file lands first — never deployed by trusty-mpm.
        std::fs::create_dir_all(ws_fw.agent_deploy_dir()).unwrap();
        let hand_placed = ws_fw.agent_deploy_dir().join("rust-engineer.md");
        let content = "---\nname: rust-engineer\n---\noperator's own agent";
        std::fs::write(&hand_placed, content).unwrap();

        let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
        assert!(
            report
                .agents_skipped
                .contains(&"rust-engineer.md".to_string())
        );
        assert_eq!(std::fs::read_to_string(&hand_placed).unwrap(), content);
    }

    #[test]
    fn sync_assets_repairs_drifted_bundled_agent() {
        // Issue #4408: a BUNDLED agent this deployer wrote is framework-owned
        // (the `InstallPolicy::Overwrite` tier), so a deployed copy that
        // drifted from its manifest checksum — here the degenerate stub from
        // the incident — is refreshed from the bundle rather than frozen as a
        // user edit. Freezing it made the agent unrecoverable in that
        // workspace forever.
        let tmp = tempfile::tempdir().unwrap();
        let mut fw = FrameworkPaths::under(tmp.path().join("home"));
        fw.trusty_mpm_root = None;
        let bundled = fw.agent_source_dir();
        std::fs::create_dir_all(&bundled).unwrap();
        std::fs::write(
            bundled.join("rust-engineer.md"),
            "---\nname: rust-engineer\n---\nv1",
        )
        .unwrap();

        let project_dir = tmp.path().join("workspace");
        std::fs::create_dir_all(&project_dir).unwrap();
        let mut ws_fw = fw.clone();
        ws_fw.claude_agents = project_dir.join(".claude").join("agents");
        ws_fw.claude_skills = project_dir.join(".claude").join("skills");

        sync_session_assets(&ws_fw, &project_dir).unwrap();
        let deployed = ws_fw.agent_deploy_dir().join("rust-engineer.md");
        std::fs::write(&deployed, "---\nname: rust-engineer\n---\n\nv1\n").unwrap();

        std::fs::write(
            bundled.join("rust-engineer.md"),
            "---\nname: rust-engineer\n---\nv2",
        )
        .unwrap();
        let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
        assert!(
            report
                .agents_deployed
                .contains(&"rust-engineer.md".to_string()),
            "drifted bundled agent must be repaired, got: {report:?}"
        );
        assert!(
            !report
                .agents_skipped
                .contains(&"rust-engineer.md".to_string())
        );
        assert!(
            std::fs::read_to_string(&deployed).unwrap().contains("v2"),
            "the current bundled composition must be restored"
        );
    }

    #[test]
    fn sync_assets_retracts_workspace_bundled_agents() {
        // Issue #4409: a workspace provisioned by an OLDER binary carries a
        // full bundled roster in its own `.claude/agents/`, which outranks the
        // config-dir tier. Syncing must clear those framework-owned copies —
        // and must leave a hand-placed agent in the same directory alone.
        let tmp = tempfile::tempdir().unwrap();
        let mut fw = FrameworkPaths::under(tmp.path().join("home"));
        fw.trusty_mpm_root = None;
        let bundled = fw.agent_source_dir();
        std::fs::create_dir_all(&bundled).unwrap();
        std::fs::write(
            bundled.join("rust-engineer.md"),
            "---\nname: rust-engineer\n---\nv1",
        )
        .unwrap();

        let project_dir = tmp.path().join("workspace");
        std::fs::create_dir_all(&project_dir).unwrap();
        let mut ws_fw = fw.clone();
        ws_fw.claude_agents = project_dir.join(".claude").join("agents");
        ws_fw.claude_skills = project_dir.join(".claude").join("skills");

        // Simulate the legacy per-workspace deploy an older binary performed.
        let legacy = ws_fw.claude_agents_dir();
        deploy_agents_filtered(&bundled, &legacy, &std::env::temp_dir(), |_| true).unwrap();
        assert!(legacy.join("rust-engineer.md").exists());

        // …plus an agent the operator dropped in by hand, which trusty-mpm
        // never deployed and must never remove.
        let hand_placed = legacy.join("my-own-agent.md");
        let mine = "---\nname: my-own-agent\ndescription: mine\n---\n\nMine.\n";
        std::fs::write(&hand_placed, mine).unwrap();

        sync_session_assets(&ws_fw, &project_dir).unwrap();

        assert!(
            !legacy.join("rust-engineer.md").exists(),
            "the legacy per-workspace bundled copy must be retracted"
        );
        assert_eq!(
            std::fs::read_to_string(&hand_placed).unwrap(),
            mine,
            "a hand-placed agent must survive retraction byte-identical"
        );
        assert!(
            ws_fw.agent_deploy_dir().join("rust-engineer.md").exists(),
            "the canonical config-dir tier must carry the roster instead"
        );
    }

    #[test]
    fn sync_assets_reports_retraction_failure_on_corrupt_manifest() {
        // Whether a workspace is still shadowing the canonical roster is the
        // signal an operator running sync-assets needs. A bare log line meant
        // the CLI and daemon route could not see it, while the
        // `prepare_session` path reported the identical failure through
        // `roster_errors` — an asymmetry on the one thing worth surfacing.
        let tmp = tempfile::tempdir().unwrap();
        let mut fw = FrameworkPaths::under(tmp.path().join("home"));
        fw.trusty_mpm_root = None;
        let bundled = fw.agent_source_dir();
        std::fs::create_dir_all(&bundled).unwrap();
        std::fs::write(
            bundled.join("rust-engineer.md"),
            "---\nname: rust-engineer\n---\nv1",
        )
        .unwrap();

        let project_dir = tmp.path().join("workspace");
        std::fs::create_dir_all(&project_dir).unwrap();
        let mut ws_fw = fw.clone();
        ws_fw.claude_agents = project_dir.join(".claude").join("agents");
        ws_fw.claude_skills = project_dir.join(".claude").join("skills");

        // An unreadable ownership ledger in the workspace: retraction refuses
        // to act on it (correctly), and that refusal must reach the report.
        let legacy = project_dir.join(".claude").join("agents");
        std::fs::create_dir_all(&legacy).unwrap();
        std::fs::write(
            legacy.join(crate::core::agent_manifest::MANIFEST_FILE),
            b"not valid json{{{",
        )
        .unwrap();

        let report = sync_session_assets(&ws_fw, &project_dir).unwrap();

        assert!(
            report
                .retraction_error
                .as_deref()
                .is_some_and(|e| e.contains("corrupt")),
            "the refusal must be reported, got: {:?}",
            report.retraction_error
        );
        // The asset refresh itself still succeeded — retraction is non-fatal.
        assert!(
            report
                .agents_deployed
                .contains(&"rust-engineer.md".to_string())
        );
    }
}