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
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
//! The `tm doctor` diagnostic engine.
//!
//! Why: a misconfigured trusty-mpm stack fails in confusing ways — sessions
//! launch with no instructions, agents never deploy, memory recall silently
//! returns nothing. `tm doctor` collapses every "is this wired correctly?"
//! question into one command so the operator gets a single, actionable verdict.
//! What: [`run_doctor`] runs independent probes — the instruction pipeline,
//! agent deployment, skill deployment, the DOC-28 output-style
//! configuration check (see `doctor_output_style`), and the trusty-memory /
//! trusty-search sidecars — and folds their outcomes into a
//! [`DoctorReport`]. Each network probe is bounded by [`PROBE_TIMEOUT`] so an
//! unreachable service cannot hang the report.
//! Test: `cargo test -p trusty-mpm-daemon doctor` exercises the filesystem
//! probes against temp directories and the socket probes against an in-process
//! test server.

use std::path::{Path, PathBuf};
// #7685: `std::time::Duration` is spelled out at its two const sites rather than
// imported — this file sits AT the 500-SLOC production cap, and a new check's
// `use` line had to come from somewhere.

// #7685: `CheckStatus`/`DoctorCheck` moved out with the sidecar probes — this
// file registers checks other modules construct, and no longer builds one.
use crate::core::doctor::DoctorReport;
use crate::core::paths::FrameworkPaths;
// #7259: doctor's claim set now carries liveness, produced in one place by
// `SessionManager::workspace_claims`.
use crate::session_manager::worktree_reclaim::{ClaimLiveness, LiveClaims, WorkspaceClaim};

// Split out to keep this file under the 500-SLOC production cap (#5947 — the
// worktrees probe now reads the reconciled inventory, and its counts type and
// remediation constant came with it).
#[path = "doctor_worktrees.rs"]
mod doctor_worktrees;
use doctor_worktrees::check_worktrees;
pub(crate) use doctor_worktrees::gather_worktree_counts;
pub use doctor_worktrees::{WORKTREE_REMEDIATION_COMMAND, WorktreeOrphanCounts};

// #3605: `check_worktrees` above counts orphaned worktree DIRECTORIES. This is
// the opposite condition — a live worktree whose base clone stopped resolving,
// which the 2026-07-21 incident left undetected for over half an hour.
#[path = "doctor_base_clone.rs"]
mod doctor_base_clone;
use doctor_base_clone::check_base_clones;

// Split out to keep this file under the 500-SLOC production cap (DOC-28 R4(a)).
#[path = "doctor_output_style.rs"]
mod doctor_output_style;
use doctor_output_style::{
    check_output_style, check_output_style_legacy_ids, check_output_style_staleness,
};

// Split out to keep this file under the 500-SLOC production cap (A2,
// tm-skills-portfolio epic — adding check_skill_source pushed it over).
#[path = "doctor_fs_checks.rs"]
mod doctor_fs_checks;
use doctor_fs_checks::{check_agents, check_instructions, check_skill_source, check_skills};

// Split out to keep this file under the 500-SLOC production cap (issue #2158
// — the full manifest-completeness diff, layered on top of the narrower
// check_agents/check_skills/check_output_style probes above).
#[path = "doctor_deploy_validate.rs"]
mod doctor_deploy_validate;
use doctor_deploy_validate::check_deployment_completeness;

// #4451: the resolvability half — `check_agents` and
// `check_deployment_completeness` above are both presence-only and stayed green
// while the harness could not reach a single deployed agent.
#[path = "doctor_agent_reachability.rs"]
mod doctor_agent_reachability;
use doctor_agent_reachability::check_agent_reachability;

// #4442: the shadowing half — every probe above looks only at the canonical
// deploy tier, so a stale copy of the same agent in a project's
// `.claude/agents/` outranks it and wins resolution with all checks green.
#[path = "doctor_asset_tier.rs"]
mod doctor_asset_tier;
use doctor_asset_tier::check_asset_tier;
// #6649: and this proves no single tier holds one name twice — the collision
// `asset_tier` and `skill_project_tier` are both structurally unable to see.
#[path = "doctor_asset_duplicates.rs"]
mod doctor_asset_duplicates;
use doctor_asset_duplicates::check_asset_duplicates;

// #4467: the same silent-failure shape as #4451, one layer down — a managed
// spawn that inherits `CLAUDE_CODE_CHILD_SESSION` has transcript saving turned
// off, so the session is unrecoverable and nothing reported it.
#[path = "doctor_transcript_saving.rs"]
mod doctor_transcript_saving;
use doctor_transcript_saving::check_transcript_saving;

// #7424 and #7685: the startup-context and auto-memory rows. Their `mod`
// declarations are in `daemon/mod.rs` rather than here, and the two imports
// share one statement — this file sits AT the 500-SLOC production cap, so a
// `#[path] mod` pair per check does not fit.
use super::{doctor_auto_memory::check_auto_memory, doctor_startup_context::check_startup_context};

// #7673: every CLAUDE.md ABOVE the project root.
#[path = "doctor_ancestor_claude_md.rs"]
mod doctor_ancestor_claude_md;
use doctor_ancestor_claude_md::check_ancestor_claude_md;

// Split out to keep this file under the 500-SLOC production cap (issue #2876 —
// the skill-staleness and legacy-instruction-source probes).
#[path = "doctor_staleness.rs"]
mod doctor_staleness;
use doctor_staleness::check_legacy_instruction_sources;

// #4604: deployed-skill drift, compared against the RUNNING BINARY's own
// embedded assets. It used to live in `doctor_staleness.rs` and compare the
// `~/.trusty-mpm/framework/skills` extraction cache against the deploy
// manifest — both sides could be the same stale content, which is how a
// drifted `tm-workflow` reported clean at three tiers at once.
#[path = "doctor_skill_drift.rs"]
mod doctor_skill_drift;
use doctor_skill_drift::check_skill_staleness;

// #4033: install provenance of the running binary — "is what's running what
// you think it is?", the observation doctor's liveness+file-state health model
// never made.
#[path = "doctor_binary_provenance.rs"]
mod doctor_binary_provenance;
use doctor_binary_provenance::check_binary_provenance;

// #8482: the row above orders two RELEASE labels against cargo's registry
// ledger and never reads the source tree; this one compares the binary's
// EMBEDDED assets against `origin/main`, which is the only way a binary whose
// bundled skills lag the repo is visible at all — `skill_staleness` compares
// deployed files against those same embedded assets and is structurally blind.
#[path = "doctor_bundled_asset_lag.rs"]
mod doctor_bundled_asset_lag;
use doctor_bundled_asset_lag::check_bundled_asset_lag;

// #4605: the reachability half for SKILLS — `check_skill_staleness` above
// compares against the deploy MANIFEST, so a bundled skill absent from that
// manifest is outside everything it can see and reports a clean `Ok` while the
// file on disk serves text the current binary removed.
#[path = "doctor_skill_unmanaged.rs"]
mod doctor_skill_unmanaged;
use doctor_skill_unmanaged::check_skill_unmanaged;

// #6586: bundled skills are user-tier only. The deploy sites honour that now,
// but they cannot reach a copy an earlier binary already wrote into a project.
#[path = "doctor_skill_project_tier.rs"]
mod doctor_skill_project_tier;
use doctor_skill_project_tier::check_skill_project_tier;

// #4947: the skill mirror of `agent_reachability`. Every probe above audits
// something INSIDE the tiers it is handed — checksums, a deploy ledger, a
// retired duplicate — and none can fail when a rostered skill reaches no tier
// at all, which is what `deploy_skills_filtered` did to directory-shaped skills
// for weeks (#4949).
#[path = "doctor_skill_reachability.rs"]
mod doctor_skill_reachability;
use doctor_skill_reachability::check_skill_reachability;

// Split out to keep this file under the 500-SLOC production cap (DOC-42,
// issue #2889 — the agent-bundled-skills dangling-reference / prose-mention
// probe).
#[path = "doctor_agent_skills.rs"]
mod doctor_agent_skills;
use doctor_agent_skills::check_agent_skills;

// #5032: the `gh_account` probe, split out when its tri-state outcome
// (authenticated / unauthenticated / could-not-tell) pushed this file over cap.
#[path = "doctor_gh_account.rs"]
mod doctor_gh_account;
use doctor_gh_account::check_gh_account;

// #7311: rtk is an install dependency; never run rtk init.
#[path = "doctor_rtk.rs"]
mod doctor_rtk;
use doctor_rtk::check_rtk;

// #7097: the `issue_audit_recent` sweep — the unprompted half of the ticketing
// standard's mechanical read-back. Its own file for the same 500-SLOC reason.
#[path = "doctor_issue_audit.rs"]
mod doctor_issue_audit;
use doctor_issue_audit::check_issue_audit_recent;

// Split out to keep this file under the 500-SLOC production cap (issue #2940
// — the tm hook contamination / foreign claude-mpm hook conflict probe).
#[path = "doctor_hooks_hygiene.rs"]
// #5274/ADR-0037: `pub(crate)` so the launch path can reuse this probe's own
// file enumeration and parsing instead of growing a second detector.
pub(crate) mod doctor_hooks_hygiene;
use doctor_hooks_hygiene::check_hooks_hygiene;

// Split out to keep this file under the 500-SLOC production cap (issue #2997 —
// the macOS TCC responsibility-disclaim visibility probe).
#[path = "doctor_tcc.rs"]
mod doctor_tcc;
use doctor_tcc::check_tcc_taint;

// Split out to keep this file under the 500-SLOC production cap (issue #3427 —
// the harness-scaffolding tracked-in-git-AND-regenerated-locally probe).
#[path = "doctor_scaffold_tracking.rs"]
mod doctor_scaffold_tracking;
use doctor_scaffold_tracking::check_scaffold_tracking;

// Split out to keep this file under the 500-SLOC production cap (issue #2867 —
// the cross-branch push-guard coverage probe, which is what makes an
// unprotected already-provisioned base clone discoverable at all).
#[path = "doctor_push_guard.rs"]
mod doctor_push_guard;
use doctor_push_guard::check_push_guard;

// Split out to keep this file under the 500-SLOC production cap (issue #7171 —
// the git-maintenance-storm detection probes: an un-pinned base clone above
// the worktree threshold, and more than one live `git maintenance run`).
#[path = "doctor_maintenance_storm.rs"]
mod doctor_maintenance_storm;
use doctor_maintenance_storm::{check_live_maintenance_processes, check_maintenance_config};

// Split out to keep this file under the 500-SLOC production cap (issue #2919 —
// the worktree disk-consumption / merged-PR reclaimability probe, the
// early-warning half of the 1.1 TiB leak post-mortem).
#[path = "doctor_worktree_disk.rs"]
mod doctor_worktree_disk;
use doctor_worktree_disk::check_worktree_disk;

// #8994: count and GiB per project from the worktree ledger alone.
#[path = "doctor_worktree_registry.rs"]
pub(crate) mod doctor_worktree_registry;

// #7497: the check above counts the BYTES tm's own worktrees hold; this one
// reports the MOUNT's headroom against `disk.max_usage_pct`, which is what
// decides whether the next worktree is created at all.
#[path = "doctor_disk_usage.rs"]
mod doctor_disk_usage;
use doctor_disk_usage::check_disk_usage;
#[path = "doctor_pty_headroom.rs"]
mod doctor_pty_headroom;
use doctor_pty_headroom::check_pty_headroom;

// See #6868 — the two rows above measure disk this machine HAS SPENT; this one
// reports the Rust build settings that decide how much a dispatched engineer
// spends next (shared cargo target dir, job count, sccache posture), and prints
// the prefix line a PM pastes into a brief.
#[path = "doctor_rust_build_env.rs"]
mod doctor_rust_build_env;
use doctor_rust_build_env::check_rust_build_env;

// #6535: the cloud log drain runs inside the daemon and writes to somebody
// else's bucket, so nothing else on screen says whether it is on, where it
// points, or whether the last pass worked.
#[path = "doctor_log_drain.rs"]
mod doctor_log_drain;
use doctor_log_drain::check_log_drain;

// Split out to keep this file under the 500-SLOC production cap (issue #4286 —
// the retired `.trusty-mpm/` override-file probe, the on-demand half of the
// signal that stops the hard cut from silently dropping a project's rules).
#[path = "doctor_legacy_overrides.rs"]
mod doctor_legacy_overrides;
use doctor_legacy_overrides::check_legacy_overrides;
// #8453: which instruction profile a launch here resolves, and why not.
#[path = "doctor_session_profile.rs"]
mod doctor_session_profile;
use doctor_session_profile::check_session_profile;
// #9018: whether `[pm_guard] enabled = false` has turned the guard off.
#[path = "doctor_pm_guard.rs"]
mod doctor_pm_guard;
use doctor_pm_guard::check_pm_guard;
// #9091: whether the `[accounts]` org → gh account table reads.
#[path = "doctor_org_accounts.rs"]
mod doctor_org_accounts;
use doctor_org_accounts::check_org_accounts;

// #7616: the fold's decline was reported only as a one-time daemon-log line, so
// nothing an operator reads said whether the instruction fold saves anything.
#[path = "doctor_instruction_fold.rs"]
mod doctor_instruction_fold;
use doctor_instruction_fold::check_instruction_fold;

// #7867: what the 💸 segment actually measures — tool-output compression, from
// the segment's own ledger rows. The row above answers about a different
// measurement that used to share the word "compression".
#[path = "doctor_tool_output_compression.rs"]
mod doctor_tool_output_compression;
use doctor_tool_output_compression::check_tool_output_compression;

// #7617: whether the 💸 segment is wired at all — the settings tiers, the
// readability of its two inputs, and the render rule. Three disappearances had
// no surface saying any of that.
#[path = "doctor_statusline.rs"]
pub(crate) mod doctor_statusline;
use doctor_statusline::check_statusline;

// #5045: the resolution half of `check_search` below. That probe asks the
// daemon whether it is healthy and whether the DERIVED id appears in
// `search.indexes.list`; this one resolves the id the session is actually
// PINNED to. The
// registration path is fail-open at every step, so the pin advances even when
// index creation failed — 4 of 75 live worktrees had an index while `search`
// reported fine.
#[path = "doctor_search_pin.rs"]
mod doctor_search_pin;
use doctor_search_pin::check_search_index_pin;

// Split out to keep this file under the 500-SLOC production cap (issue #5007 —
// the managed-session-store integrity probe; a corrupt `sessions.json` blocks
// every write and was previously invisible to every diagnostic).
#[path = "doctor_session_store.rs"]
mod doctor_session_store;
use doctor_session_store::check_session_store;
// #8980: a `session-claudes.json` the daemon cannot trust seals the registry.
#[path = "doctor_session_claudes.rs"]
mod doctor_session_claudes;
pub(crate) use doctor_session_claudes::apply_daemon_seal;
use doctor_session_claudes::check_session_claudes;
// #9238: a record whose pane is live under another tmux session name.
#[path = "doctor_session_names.rs"]
pub(crate) mod doctor_session_names;
// Claude Code finds `.mcp.json` by walking UP from a session's cwd, so one
// written above real projects configures every session beneath it with nothing
// in the project to point at. Read-only; the quarantine is opt-in.
#[path = "doctor_stray_mcp.rs"]
mod doctor_stray_mcp;
use doctor_stray_mcp::check_stray_mcp_json;
// #7422: default-deny scoping changes what a session loads without changing
// anything the operator can see. This names what a project stopped loading.
#[path = "doctor_session_scope.rs"]
mod doctor_session_scope;
use doctor_session_scope::check_session_scope;
// #6469: a tmux server tm did not start carries none of tm's server globals —
// a tmux-resurrect restore leaves every restored pane on the factory 2000-line
// scrollback. Read-only: it reads options, never sets one.
// #7422: split out when the `session_scope` check pushed this file over the
// 500-SLOC production cap; the probe itself is unchanged.
#[path = "doctor_oauth_token.rs"]
mod doctor_oauth_token;
use doctor_oauth_token::check_oauth_token_config;
// The pure verdict half has no production caller here — `doctor_tests.rs`
// exercises it directly through this module's `use super::*`.
#[cfg(test)]
use doctor_oauth_token::build_oauth_token_check;
#[path = "doctor_tmux_options.rs"]
mod doctor_tmux_options;
use doctor_tmux_options::check_tmux_options;

// #7685: the trusty-memory / trusty-search health probes and their retry,
// classification and index helpers. Split out because this file is the check
// REGISTRY and was sitting at the 500-SLOC production cap — see that module's
// own doc. `pub(crate)` because `PROBE_TIMEOUT` is part of the daemon's
// probe-budget vocabulary; the checks themselves stay `pub(super)`.
#[path = "doctor_sidecars.rs"]
pub(crate) mod doctor_sidecars;
/// Re-exported so `doctor_search_pin` keeps naming one probe budget (#7685).
pub(crate) use doctor_sidecars::PROBE_TIMEOUT;
use doctor_sidecars::{check_memory, check_search};

// #4003: the search-index doctor probe used to hardcode a literal expected
// index id ("trusty-mpm" — the crate name, not this repo's registered index
// id "trusty-tools"), so it permanently warned "index missing" against a
// healthy, fully-indexed project. `expected_search_index_id` below resolves
// the id the same way every other launch path does, via
// `trusty_common::derive_index_id`.

/// Run every diagnostic probe and assemble the report.
///
/// Why: the single entry point behind `GET /api/v1/doctor` and `tm doctor` —
/// running all probes here keeps the check set identical across every UI.
/// What: runs the instruction / agent / skill / output-style filesystem
/// probes (the output-style probe closes DOC-28 F4 — the "did the
/// trusty-mpm instructions actually load" gap), the `deployment` probe (issue
/// #2158 — a full manifest-completeness diff against the canonical bundled
/// roster, layered on top of the narrower agent/skill/output-style probes),
/// then the memory and search socket probes (each bounded by [`PROBE_TIMEOUT`]),
/// a worktree-orphan scan (Fix 1b, #1840), the `skill_source` probe (A2,
/// tm-skills-portfolio epic), the `gh_account` probe
/// (#gh-account-awareness — surfaces the active github.com identity and warns
/// on the multi-account ambiguity), the `rtk` probe (issue #7311 — warns when
/// the binary `tm compress` shells out to is absent and the slower native
/// fallback is running silently), and the `oauth_token` probe (issue #2246 —
/// warns when a managed session risks the `CLAUDE_CONFIG_DIR`-keyed Keychain
/// login loop), the `skill_staleness` and `legacy_sources` probes (issue #2876
/// — warn when deployed skills differ from the bundled assets, or when legacy
/// global instruction sources linger), and the `agent_skills` /
/// `agent_skills_prose_hints` probe pair (DOC-42, issue #2889 — the former
/// flags dangling `skills:` frontmatter references and can `Warn`; the
/// latter is always `Ok` and carries undeclared prose skill mentions as
/// informational text, per issue #2906 review — folding both severities
/// into one check caused alert fatigue), and the `hooks_contamination` /
/// `hooks_foreign_conflict` probe pair (issue #2940 — the former warns when
/// a project-level `.claude/settings*.json` still carries tm hook entries
/// from a pre-fix `tm install`'s `$HOME`-wide write and points at `tm hooks
/// clean`; the latter warns, informationally only, when a project carries
/// its own claude-mpm hook entries that would fire inside a tm session), and
/// the `output_style_staleness` probe (issue #2333 — content-diffs each
/// deployed output-style file against the bundled catalog and flags orphaned
/// files under `output-styles/`, closing the gap where `check_output_style`
/// only validates that the configured id RESOLVES to a file, not that its
/// content is current), the `output_style_legacy_ids` probe (issue #3453 part
/// 2 — Warn-only scan of every settings LAYER, not just the effective one,
/// for a legacy/unresolvable `outputStyle` id sitting dormant in a currently
/// shadowed layer such as `settings.local.json`), the `tcc_taint` probe
/// (issue #2997 — surfaces whether managed panes spawn `claude` with macOS
/// TCC responsibility disclaimed, so the "would like to access data…" prompt
/// class is diagnosable rather than silent; synchronous and instantaneous, no
/// `log show` scan), and the `scaffold_tracking` probe (issue #3427 — warns
/// when a harness-owned path under `.claude/agents/`, `.claude/skills/`, or
/// `.claude/output-styles/` is BOTH tracked in `project_dir`'s git index AND
/// regenerated locally by tm, the precondition for a `git merge --ff-only`
/// "would be overwritten" collision; reports the exact true-intersection
/// paths plus a copy-pasteable `git rm -r --cached` remediation, never runs
/// it itself), and the `push_guard` probe (issue #2867 — warns when
/// `project_dir`'s clone has no trusty-mpm cross-branch `pre-push` guard, or
/// carries an older revision of it, naming the `tm repair push-guard`
/// retrofit; this is the only way a base clone provisioned BEFORE the guard
/// shipped is discoverable as unprotected), and the `search_index_pin` probe
/// (issue #5045 — resolves the index id the project's `.mcp.json` actually
/// PINS against `GET /indexes/{id}/status` and `Fail`s on a 404; index
/// registration is fail-open at every step, so the pin advances even when
/// creation failed and the `search` probe above stays green) — folding the
/// resulting [`DoctorCheck`](crate::core::doctor::DoctorCheck)s into a
/// [`DoctorReport`] whose `overall` status
/// is the worst of them.
///
/// Note (#1905): the mpm-*→tm-* stale-skill cleanup is intentionally NOT a
/// permanent probe here — it is a one-time migration
/// ([`crate::core::stale_skills::run_stale_mpm_skills_migration_once`]) run
/// from `tm`'s startup path instead, so it does real work at most once per
/// machine rather than nagging on every `tm doctor` invocation forever.
///
/// `project_dir` scopes the instruction and output-style probes; `repos_root`
/// (when `Some`) gives the managed workspace root for the worktree scan;
/// `active_workspace_paths` is the full set of workspace paths currently
/// registered to live sessions.
///
/// Issue #2149: `check_skills` is ALSO scoped by `project_dir` when it is
/// supplied. A managed session (#1931) deploys its SKILLS under
/// `<workspace>/.claude/skills`, not the operator's `$HOME/.claude` — probing
/// only the home-tier directory previously let a managed workspace with a
/// completely empty roster report a false `Ok` (because the operator's OWN
/// `$HOME/.claude/` was populated), silently missing the exact provisioning
/// gap that issue is about.
///
/// Issue #5867 narrows that scoping to a project that IS a managed workspace,
/// decided by [`is_managed_workspace`] against `active_workspace_paths`. An
/// unmanaged cwd now resolves the home layout, so the three skill deploy tiers
/// stay distinct and `~/.claude/skills` is audited again. Two probes keep the
/// old workspace-scoped layout on purpose — `deployment` and `agent_skills`,
/// whose subject is a workspace's own `.claude/` payload, not the tier model.
///
/// Issue #4409 removes AGENTS from that scoping: bundled agents deploy into
/// the one tm-managed `CLAUDE_CONFIG_DIR` tier and nowhere else, so
/// `check_agents`/`check_agent_skills` probe `paths.agent_deploy_dir()`, which
/// is the same directory whether or not a `project_dir` was supplied.
/// Test: `run_doctor_produces_sixty_eight_checks`,
/// `agents_check_probes_the_managed_config_tier_not_the_workspace`.
pub async fn run_doctor(
    project_dir: Option<&Path>,
    repos_root: Option<&Path>,
    active_workspace_paths: &[PathBuf],
    // #5947: the caller gathers the reconciled inventory, so doctor reports the
    // same orphan count `prune-worktrees` and `reconcile-worktrees` agree on.
    worktree_counts: Option<WorktreeOrphanCounts>,
) -> DoctorReport {
    // #7259: a caller holding only paths has no liveness to hand over, so every
    // path becomes a live unattributed claim. That is the pre-#7232 reading and
    // the fail-closed one — a claim can only be marked gone by a probe that
    // actually answered, which is what [`run_doctor_for_manager`] supplies.
    let active = LiveClaims::foreign(
        active_workspace_paths
            .iter()
            .map(WorkspaceClaim::unattributed)
            .collect(),
    );
    run_doctor_with_claims(project_dir, repos_root, &active, worktree_counts).await
}

/// [`run_doctor`] over a claim set whose liveness has already been probed
/// (#7259).
///
/// Why: `LiveClaims` is crate-private, so the public entry point above cannot
/// name it — and the fleet-aware caller must be able to pass liveness through
/// rather than flatten it back to paths, which is the flattening that let a
/// tombstoned org-level claim hide every worktree beneath it.
/// What: the whole check battery. `active` reaches `check_worktree_disk`
/// unchanged; the four probes that only ask "which workspaces are live" read
/// [`live_workspace_paths`], which drops the claims a tmux probe answered for
/// and found gone.
/// Test: `a_dead_sessions_org_level_claim_no_longer_hides_orphaned_disk`,
/// `live_workspace_paths_drops_only_claims_a_probe_found_gone`.
pub(crate) async fn run_doctor_with_claims(
    project_dir: Option<&Path>,
    repos_root: Option<&Path>,
    active: &LiveClaims,
    worktree_counts: Option<WorktreeOrphanCounts>,
) -> DoctorReport {
    let live_paths = live_workspace_paths(active);
    let active_workspace_paths: &[PathBuf] = &live_paths;
    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    // #5867: only a project that IS a managed workspace gets the workspace
    // layout. `tm doctor` sends the process cwd unconditionally, so an ordinary
    // checkout took the managed arm too and `claude_skills_dir()` pointed at
    // `<cwd>/.claude/skills` — the same path the "project" tier candidate
    // produces, which `skill_deploy_tiers`' dedup then dropped. The operator's
    // real `~/.claude/skills` went unaudited and project-tier findings surfaced
    // labelled "operator home".
    let managed_workspace =
        project_dir.filter(|dir| is_managed_workspace(dir, active_workspace_paths));
    let paths = match managed_workspace {
        Some(dir) => FrameworkPaths::for_managed_workspace(dir),
        None => FrameworkPaths::default(),
    };
    // The workspace-scoped layout, kept for the two probes whose SUBJECT is a
    // provisioned workspace's own `.claude/` payload rather than the deploy-tier
    // model: `check_deployment_completeness` (the same diff `tm validate` runs
    // against a workspace) and `check_agent_skills` (which resolves an agent's
    // `skills:` reference against the project tier the session will read). #5867
    // deliberately leaves both unchanged.
    let workspace_paths = match project_dir {
        Some(dir) => FrameworkPaths::for_managed_workspace(dir),
        None => FrameworkPaths::default(),
    };
    // Skills are probed at the same root the roster deploys to: the workspace
    // itself for a managed session, else the operator's home directory (#2149,
    // narrowed by #5867 — an unmanaged cwd has no workspace deploy to probe).
    let skills_root: &Path = managed_workspace.unwrap_or(&home);

    // DOC-42 issue #2906 review (MEDIUM finding): dangling `skills:`
    // references and informational prose-mention hints carry different
    // severities, so `check_agent_skills` now returns two independent
    // checks from one scan rather than folding both into a single `Warn`.
    let (agent_skills, agent_skills_prose_hints) = check_agent_skills(&workspace_paths);

    let mut checks = vec![
        check_instructions(project_dir),
        check_agents(&paths, project_dir),
        // #4451: `check_agents` proves the files exist; this proves the tier
        // they land in is one a managed session's harness actually scans.
        check_agent_reachability(&paths, project_dir),
        // #4442: and this proves nothing OUTSIDE that tier outranks it — a
        // project-tier copy of a bundled agent shadows the canonical deploy
        // while every presence-only probe above stays green.
        check_asset_tier(&paths, project_dir, &home),
        // #6649: and this proves neither tier holds one asset NAME twice —
        // `foo.md` beside `foo/`, or two case-variant stems. Ownership is not
        // the question, so it is its own row rather than a fold into the two
        // above. Report-only: tm cannot know which entry the operator meant.
        check_asset_duplicates(&paths, project_dir),
        // #4467: and this proves the spawn does not silently lose the session's
        // own transcript, which costs it all native --resume/--continue/rewind
        // recovery. No `project_dir`/`paths` input — the invariant is a property
        // of the spawn command itself, identical for every project.
        check_transcript_saving(),
        check_skills(skills_root),
        check_skill_source(&paths),
        // #4947: `check_skills` above counts deployed files; this proves every
        // skill the framework's own roster declares deployable actually reached
        // a tier the harness reads, parses there, and resolves under its own
        // name — and warns when two tiers hold the same one.
        check_skill_reachability(&paths, project_dir),
        check_output_style(project_dir, &home),
        check_output_style_staleness(project_dir, &home, &paths),
        check_output_style_legacy_ids(project_dir, &home),
        check_deployment_completeness(&workspace_paths),
        check_skill_staleness(&paths, project_dir),
        // #4605: and this proves the manifest that check consults actually
        // covers the deployed skills — an untracked bundled skill is
        // unreachable by every deploy and invisible to staleness.
        check_skill_unmanaged(&paths, project_dir),
        // #6586: and this reports the duplicates the ruling retired — a bundled
        // skill still sitting in a project's own tier, which no deploy refreshes.
        check_skill_project_tier(&paths, project_dir),
        check_legacy_instruction_sources(&home),
        // #4286: the five `.trusty-mpm/` override files are no longer read. A
        // leftover file means the project's instructions stopped reaching the
        // PM, so this Fails loudly and names the CLAUDE.md migration.
        check_legacy_overrides(project_dir),
        // #8453: a project asking for the supervisor profile without the
        // operator's `[supervisor] projects` entry runs as a PM; say why.
        check_session_profile(project_dir, &home),
        // #9018: and whether the PM guard that profile is enforced by is on.
        check_pm_guard(&home),
        // #9091: a broken table refuses clones and fails spawns closed.
        check_org_accounts(&home),
        // #7616: states whether the instruction fold is saving anything for this
        // project, so a daemon-log line stops being the only evidence.
        check_instruction_fold(project_dir),
        // #7867: and this one states what the 💸 segment itself measures — the
        // `compress` and `divert` rows on the ledger it folds. The row above
        // answers a different question that used to share the word
        // "compression".
        check_tool_output_compression(&paths.root),
        // #7617: and this one reports whether the 💸 segment can render AT ALL —
        // the `statusLine` entry in each tier, whether the ledger and the
        // per-session record store are readable, and whether the render rule
        // produces a figure. `tool_output_compression` above answers whether
        // there is anything to show; this answers whether it could be shown.
        check_statusline(project_dir, &paths.root),
        agent_skills,
        agent_skills_prose_hints,
    ];
    checks.push(check_memory(&home).await);
    checks.push(check_search(&home, project_dir).await);
    // #5045: `check_search` above reports the daemon and the DERIVED id; this
    // resolves the id `.mcp.json` actually pins, which is what every `search`
    // call in the session sends.
    checks.push(check_search_index_pin(&home, project_dir).await);
    checks.push(check_worktrees(repos_root, worktree_counts).await);
    // #2919: `check_worktrees` above counts ORPHANS and has never reported a
    // byte, so it read identically whether the worktree store held 4 GiB or the
    // 1.1 TiB measured on 2026-07-21. This is the disk half.
    checks.push(check_worktree_disk(repos_root, active).await);
    // #7497: and this is the mount the store sits ON — the number the
    // `disk.max_usage_pct` gate reads before creating the next worktree.
    checks.push(check_disk_usage(repos_root, &home));
    // #8994: what the worktree ledger records — no git, no filesystem walk.
    checks.push(doctor_worktree_registry::check_worktree_registry(&home));
    // See #6868: the rows above measure what disk has ALREADY gone; this one
    // reports the Rust build settings that decide the next cold build's cost,
    // and prints the `CARGO_TARGET_DIR=… CARGO_BUILD_JOBS=…` line a PM pastes
    // into an engineer brief. Skips silently on a non-Rust project. Read-only:
    // `tm doctor --fix` creates the directory and seeds the `build:` section,
    // and neither ever touches `~/.cargo/config.toml`.
    checks.push(check_rust_build_env(project_dir, &home));
    // #3605: and this is the identity half — a live worktree keeps its files
    // when the base clone behind it loses its git internals, so every git
    // command there fails while both probes above stay green.
    checks.push(check_base_clones(active_workspace_paths));
    checks.push(check_gh_account().await);
    // #7311: whether `rtk` is on PATH. `tm compress` falls back to a slower
    // native compressor without it and says nothing, so an install that never
    // pulled rtk in is otherwise indistinguishable from one that did. Advisory:
    // the fallback keeps compression working, so this never Fails. rtk is a
    // BINARY dependency — `rtk init` installs a competing PreToolUse hook and
    // is never run.
    checks.push(check_rtk());
    // #7097: whether the issues opened this week actually carry the milestone,
    // project and component label the ticketing standard requires. Advisory —
    // Warn at worst, and UNDETERMINED (never Ok) when `gh` could not answer,
    // since an audit that did not run has not found the tickets clean.
    checks.push(check_issue_audit_recent(project_dir).await);
    checks.push(check_oauth_token_config());
    // #8236: the row above asks whether a credential is CONFIGURED; this asks
    // whether one is sitting in plaintext in a user-readable LaunchAgent plist.
    // Read-only and key-only — it never prints a credential value.
    // `tm doctor --fix` removes the entries.
    checks.push(crate::daemon::doctor_launchd_secrets::check_launchd_plist_secrets(&home));
    // #8236 item 8: and now the other half — once the value is OUT of the
    // plist, can the daemon's own resolver still get it? Bounded, so this row
    // cannot hang on a Keychain approval dialog either.
    // #8236: `spawn_blocking`, because each provider can park on the store's
    // 3 s bound and this runs on a tokio worker serving `GET /api/v1/doctor`.
    checks.push(crate::daemon::doctor_credential_reach::check_credential_reach_async().await);
    // #7262: the third check names each hook/statusLine command whose binary
    // lives in a Cargo build tree, which the file-counting check above cannot.
    // #7490: the fourth is the inverse of the first — a lifecycle event a
    // tm-provisioned file has NO tm hook group for, which no file-counting
    // check can see because the evidence is what is absent.
    let (contamination, foreign_conflict, build_tree_binary, missing_tm_group) =
        check_hooks_hygiene(project_dir, active_workspace_paths);
    checks.extend([
        contamination,
        foreign_conflict,
        build_tree_binary,
        missing_tm_group,
    ]);
    // Issue #2997: surface whether managed panes disclaim TCC responsibility so
    // the "trusty-mpm/tmux would like to access data…" prompt class is
    // diagnosable rather than silent. Synchronous + instantaneous (no log scan).
    checks.push(check_tcc_taint());
    // Issue #3427: warn when a harness-scaffolding path is BOTH tracked in
    // git and regenerated locally by tm — the precondition for a
    // `git merge --ff-only` "would be overwritten" collision. Warn-only;
    // never auto-modifies the git index.
    checks.push(check_scaffold_tracking(project_dir));
    // Issue #2867: the push guard installs on the CLONE path only, so a base
    // clone that predates it is silently unprotected. Warn-only, naming the
    // `tm repair push-guard` retrofit; doctor never writes into a repository.
    checks.push(check_push_guard(project_dir));
    // Issue #7171: an un-pinned base clone above the worktree threshold, and
    // more than one live `git maintenance run` process — the detection half
    // of the maintenance-storm fix (`trusty_common::git` and
    // `core::git_maintenance` are the prevention half). Both read-only.
    checks.push(check_maintenance_config(repos_root));
    checks.push(check_live_maintenance_processes());
    // #7965: each background sweep's kill switch, whether a pass is in flight,
    // and how long the last one took — so an operator can SEE a sweep overrunning
    // instead of inferring it from request latency.
    // #8059: read from the framework root, not this process's memory — doctor
    // runs daemonless, so the sweeps' own atomics are never set here.
    checks.push(
        crate::daemon::services::sweep_status::check_background_sweeps(
            &FrameworkPaths::default().root,
        ),
    );
    // Issue #4033: where the RUNNING binary came from, and whether that source
    // still exists. Reports UNKNOWN — never Ok — when provenance cannot be
    // determined. Read-only; never installs, moves, or deletes.
    checks.push(check_binary_provenance());
    // #8482: and this is the half the row above cannot reach — the binary's
    // own embedded skill assets against `origin/main`. Skips outside
    // `bobmatnyc/trusty-tools`, where the comparison is meaningless; UNKNOWN,
    // never Ok, whenever the source tree could not be read. Read-only: it
    // never fetches, installs, or deploys.
    checks.push(check_bundled_asset_lag(project_dir));
    // #8378 PR-C: the runtime content source, pin and sha256 (ADR-0064).
    checks.push(super::doctor_content::check_content(
        project_dir,
        trusty_common::content::default_cache_dir().as_deref(),
    ));
    // Issue #5007: whether `sessions.json` still parses. A corrupt store blocks
    // every write while `tm ls` keeps serving the daemon's in-memory copy, so
    // without this probe the condition is invisible until someone attempts a
    // mutation. Read-only; the repair is `tm repair session-store`.
    checks.push(check_session_store(&FrameworkPaths::default().root));
    // #8980: whether `session-claudes.json` is one the daemon trusts. An
    // untrusted file seals the registry until the daemon restarts. Read-only.
    checks.push(check_session_claudes(&FrameworkPaths::default().root));
    // #6556: undelivered `SubagentStop` records waiting on disk, or a spool the
    // hook cannot write into — the one branch where a stop is dropped outright
    // and its delegation stays Running for six hours. Read-only.
    checks.push(crate::core::stop_spool::check_stop_spool(
        &FrameworkPaths::default().root,
    ));
    // A `.mcp.json` ABOVE the workspace is read by every session whose cwd is
    // beneath it — including agent scratchpads under /tmp — and nothing else
    // reports it. Read-only, and it names the provenance verdict per file so
    // the operator can see which ones `--fix` will refuse to touch.
    checks.push(check_stray_mcp_json(
        &crate::core::mcp_provenance::default_framework_root(),
        project_dir,
        &home,
    ));
    // #7422: which shared MCP servers and installed plugins this project's
    // sessions will NOT load, and where to opt each one back in. Informational
    // — an excluded server is the designed outcome, never a fault.
    checks.push(check_session_scope(
        project_dir,
        crate::core::trusty_tools_config::managed_claude_config_dir().as_deref(),
    ));
    // #6469: whether the live tmux server's globals still match tm's spec. Says
    // nothing about panes already created — `history-limit` is captured at pane
    // creation and cannot be grown in place.
    checks.push(check_tmux_options());
    // #8415: a tmux server inherits the launchd class of the job that started
    // it; `Background` pinned every tm session to priority 4. Read-only.
    checks.push(
        super::doctor_launchd_process_type::check_launchd_process_type_in(
            &super::doctor_launchd_process_type::launch_agents_dir(&home),
        ),
    );
    checks.push(super::doctor_tmux_priority::check_tmux_priority());
    // #8926: only trusty-console may listen on TCP (ADR-0032). Read-only.
    checks.push(super::doctor_tcp_listeners::check_tcp_listeners());
    // #6529: every tmux pane holds a pseudo-terminal and macOS caps the total,
    // so a session leak becomes a bare ENXIO on the next spawn with nothing
    // naming the cause. Read-only — it counts device nodes and reaps nothing.
    checks.push(check_pty_headroom());
    // #6535: whether the cloud log drain is on, where it points, and whether
    // its last pass actually landed. Read-only — it never drains.
    checks.push(check_log_drain(&FrameworkPaths::default().root, &home));
    // #7424: this project's turn-1 startup context against its configured
    // ceiling. Warn-only, and it opens no transcript — every reading was taken
    // by the session that owned it, so the row cannot reach another project's
    // session data. Declared in `daemon/mod.rs`; this file is AT the SLOC cap.
    checks.push(check_startup_context(project_dir));
    // #7685: whether Claude Code's own auto memory is off for this project.
    // trusty-memory is the memory here; read-only, `tm doctor --fix` writes.
    checks.push(auto_memory_row(project_dir, &home).await);
    // #7673: every `CLAUDE.md` ABOVE the project root, which Claude Code
    // prepends to every session beneath it. No other row looks above the
    // project. Read-only; `tm doctor --fix --yes` is the repair.
    checks.push(check_ancestor_claude_md(
        project_dir,
        Some(home.as_path()),
        crate::core::trusty_tools_config::managed_claude_config_dir().as_deref(),
    ));

    DoctorReport::from_checks(checks)
}

/// Run the full check battery against one
/// [`crate::session_manager::SessionManager`]'s view of the fleet.
///
/// Why (#6336): [`run_doctor`] takes its fleet inputs as plain values, so the
/// daemon's `GET /api/v1/doctor` handler and the standalone `tm doctor` CLI
/// would each have to derive "which workspaces are live" and "how many
/// worktrees are orphaned" on their own — two derivations of the same fact
/// that drift. This is the one place that turns a session manager into
/// `run_doctor`'s arguments, so a daemonless CLI run and an HTTP run report the
/// identical battery. It is also why `tm doctor` needs no daemon at all: the
/// CLI builds a read-only manager over the on-disk session store and calls this
/// directly.
/// What: reads the fleet's workspace claims through
/// [`crate::session_manager::SessionManager::workspace_claims`], resolves the
/// managed workspace root from [`crate::core::trusty_tools_config`], gathers the
/// reconciled worktree counts, and delegates to [`run_doctor_with_claims`].
/// Reads only — no spawn, no write, no daemon.
///
/// #7259: the claim set used to be `mgr.list()` mapped to `workspace_path`,
/// which has no liveness in it. The store tombstones records rather than
/// dropping them, so one `deleted` adopted pane holding an ORG-level path made
/// every worktree beneath it read as in use — `tm doctor` under-reported
/// orphaned disk for the whole subtree. `workspace_claims` is the single
/// producer #7232 introduced: one `tmux list-sessions` for the set, and a claim
/// is marked gone only by a probe that ANSWERED. The record's `state` field is
/// never consulted (#2919 measured a live session in a terminal-looking
/// record), and an unobservable tmux leaves every claim live.
/// Test: `doctor_endpoint_returns_report` covers the HTTP caller;
/// `tm_doctor_reports_every_local_check_with_no_daemon`
/// (`tests/tm_doctor_standalone.rs`) covers the daemonless CLI caller.
pub async fn run_doctor_for_manager(
    mgr: &crate::session_manager::SessionManager,
    project_dir: Option<&Path>,
) -> DoctorReport {
    let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
    let repos_root = crate::core::trusty_tools_config::workspace_root(&config);
    // `None` caller: doctor reports, it never reclaims, so it has no session
    // identity to exempt — every claim it holds is foreign, as before (#6806).
    let active = mgr.workspace_claims(None).await;
    // #5947: the orphan count comes from the reconciled inventory — the same
    // classification `prune-worktrees` and `reconcile-worktrees` share.
    // #7357: the entry point resolves the adopted anchors; every scan beneath
    // it takes them as a parameter.
    let worktree_counts =
        gather_worktree_counts(mgr, &repos_root, &crate::project::default_adopted_anchors()).await;
    let report =
        run_doctor_with_claims(project_dir, Some(&repos_root), &active, worktree_counts).await;
    // #9238: needs the manager's records and tmux driver, so it is added here.
    let mut checks = report.checks;
    checks.push(doctor_session_names::check_session_names(mgr).await);
    DoctorReport::from_checks(checks)
}

/// The claimed workspace paths whose session is still live (#7259).
///
/// Why: four probes — the managed-workspace tier decision, the base-clone
/// identity check, and the two hooks-hygiene checks — ask only "which
/// workspaces belong to a running session". A tombstoned record answers that
/// question with a directory nobody occupies, which is how a `deleted` adopted
/// pane's org-level path kept counting as active.
/// What: every claim except the ones a liveness probe answered for and found
/// gone. `Live` covers both "the session is running" and "nothing could
/// establish that it is not", so an unobservable tmux yields the full set —
/// the same fail-closed direction gate 2 takes.
/// Resolve the two host facts the `auto_memory` row needs, then grade it.
///
/// Why (#7685): the check itself is a pure function of its inputs so its three
/// verdicts are testable, which leaves someone to resolve those inputs from the
/// host. Doing it here keeps the registration above a single line — two other
/// branches are adding checks to that list — and keeps the check free of
/// ambient reads.
/// What: probes trusty-memory through
/// [`crate::core::memory_reachable::probe_memory_reachability`] (the same gate the
/// launch path consults, so a session and the row grading it cannot disagree)
/// and resolves the managed Claude config dir, then calls
/// [`doctor_auto_memory::check_auto_memory`].
/// Test: the three verdicts are covered directly in `doctor_auto_memory_tests`;
/// this wiring is covered by `run_doctor_produces_sixty_eight_checks`.
async fn auto_memory_row(
    project_dir: Option<&Path>,
    home: &Path,
) -> crate::core::doctor::DoctorCheck {
    // #7685: the classified answer, so the row can say wedged versus unreachable.
    let memory = crate::core::memory_reachable::probe_memory_reachability().await;
    let config_dir = crate::core::trusty_tools_config::managed_claude_config_dir();
    check_auto_memory(project_dir, home, config_dir.as_deref(), &memory)
}

/// Test: `live_workspace_paths_drops_only_claims_a_probe_found_gone`.
fn live_workspace_paths(active: &LiveClaims) -> Vec<PathBuf> {
    active
        .claims
        .iter()
        .filter(|c| c.liveness != ClaimLiveness::SessionGone)
        .map(|c| c.path.clone())
        .collect()
}

/// Is `project_dir` a workspace some live session was provisioned into?
///
/// Why (#5867): [`FrameworkPaths::for_managed_workspace`] rewrites the SKILL
/// deploy destination to `<dir>/.claude/skills`, which is true only of a
/// managed session's own workspace. Every other production call site already
/// passes one; `run_doctor` is the only one handed an arbitrary process cwd,
/// and applying the workspace layout there collapsed the operator-home tier
/// onto the project tier. `active_workspace_paths` is exactly the set of
/// provisioned workspaces — `daemon::api::doctor` builds it from every session
/// record's `workspace_path` — so it is the only input that can answer this
/// without inventing a heuristic.
/// What: canonicalizes both sides (a workspace under `/tmp` resolves through a
/// symlink on macOS, so a raw `==` would miss the match) and reports whether
/// `project_dir` appears in the set. A path that cannot be canonicalized —
/// absent, dangling symlink, unreadable parent alike — falls back to its own
/// raw spelling, so it is then compared verbatim. That is the safe answer in
/// the sense that matters: it can still match a recorded workspace under the
/// exact name the session recorded, but it can never match an unregistered
/// directory, which is the promotion #5867 is about.
/// Test: `unmanaged_cwd_audits_the_operator_home_tier`,
/// `a_registered_workspace_still_gets_the_workspace_layout`,
/// `an_uncanonicalizable_path_is_not_a_managed_workspace`,
/// `an_unreadable_directory_is_not_a_managed_workspace`,
/// `an_absent_path_still_matches_the_recorded_spelling_of_itself`.
fn is_managed_workspace(project_dir: &Path, active_workspace_paths: &[PathBuf]) -> bool {
    fn resolve(path: &Path) -> PathBuf {
        std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
    }
    let target = resolve(project_dir);
    active_workspace_paths
        .iter()
        .any(|candidate| resolve(candidate) == target)
}

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