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
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
//! Instruction merge pipeline — compose a session's launch instructions.
//!
//! Why: every Claude Code session trusty-mpm starts must receive the same
//! framework instructions and the dynamic delegation routing context; doing
//! that merge ad-hoc at each launch site invites drift in ordering and
//! content.
//! What: [`build_instructions`] resolves the project's agent roster, seeds a
//! project `CLAUDE.md` stub when absent — unless git says the upstream default
//! branch already tracks one, in which case the absence is branch staleness and
//! the launch is refused rather than stubbed (#5228) — and reports what it found;
//! [`compiled_prompt_path`] resolves where a session's compiled prompt lands and
//! [`write_compiled_prompt_to`] is the one writer of it.
//!
//! #4832 removed this pipeline's last text output. `build_instructions` used to
//! read `<framework_root>/instructions/INSTRUCTIONS.md` and concatenate it with
//! the delegation-authority section into `PipelineOutput::merged` — a field with
//! zero call sites, because the real launch prompt comes from
//! [`crate::core::instruction_overrides::resolve_pm_prompt`] /
//! [`crate::core::session_launch::build_system_prompt_for`]. #4752 had already
//! retired the file: nothing writes it, and `tm install` deletes a stale copy.
//! What survived was a read whose content was never used but whose failure —
//! anything other than `NotFound`: a permissions error, a directory at that
//! path, invalid UTF-8 — became `PrepError::Instructions`, the one variant
//! `is_fatal()` returns true for. It could only ever kill a session over unread
//! bytes, so the read, the field, and the merge helper are gone. The fatal gate
//! on the compiled-prompt WRITE is deliberate (#4752) and stays.
//! Test: `cargo test -p trusty-mpm instruction_pipeline` covers roster
//! resolution, stub creation, and compiled-prompt path/write behaviour.

use std::fmt;
use std::path::PathBuf;

use crate::core::delegation_authority::resolve_roster_reporting;
use crate::core::framework_content::FrameworkContent;
use crate::core::instruction_package::SectionId;

/// Separator placed between merged instruction sections.
///
/// Why: the override resolver in [`crate::core::instruction_overrides`] must use
/// the identical rule so the bundled and override-resolved prompts never
/// visually diverge.
/// What: the `\n\n---\n\n` Markdown horizontal rule used between every section.
/// Test: `separators_are_consistent` in `instruction_overrides`.
pub(crate) const SECTION_SEPARATOR: &str = "\n\n---\n\n";

// ---------------------------------------------------------------------------
// Bundled system-prompt assembly
//
// Why: trusty-mpm must own its own PM instructions rather than reading from a
// `~/.claude-mpm/` install at runtime. The section files are instructional
// content (#9012, ADR-0064): read from the loaded `FrameworkContent` and
// assembled into the prompt passed to `claude --append-system-prompt-file` on
// every session launch.
//
// SOURCE OF TRUTH (#4183): one markdown file per [`SectionId`], under
// `content/instructions/sections/`. The four monolithic assets this crate used to
// embed (`PM_INSTRUCTIONS.md`, `WORKFLOW.md`, `AGENT_DELEGATION.md`,
// `BASE_PM.md`) are gone; `pm_instructions()` and `base_pm()` reconstitute the
// two that spanned several sections so the legacy override assembly keeps its
// exact shape. Nothing is duplicated between the two models, so the composed
// package and the legacy assembly cannot drift apart in content — the property
// that makes `composed_package_is_byte_identical_to_the_legacy_bundled_fallback`
// meaningful rather than a check of two copies of the same paste.
// ---------------------------------------------------------------------------

// #8533: the section table lives in a child module (SLOC cap); re-exported so
// every existing path keeps resolving. #9012: it reads content, not constants.
#[path = "instruction_section_sources.rs"]
mod section_sources;
pub(crate) use section_sources::*;

/// The former `PM_INSTRUCTIONS.md` body, rebuilt from the PM-body sections.
///
/// Why: the legacy override assembly
/// ([`crate::core::instruction_overrides::assemble_sections`]) treats the PM body
/// as one section it may be fully replaced by `PM_INSTRUCTIONS_DEPLOYED.md`. It
/// still needs that single string, but the *authored* source is now four files.
/// Reconstituting here — rather than keeping a fifth copy on disk — is what
/// stops the legacy path and the packaged path from delivering different
/// content once a section is edited (#4183).
/// What: the `PM_BODY_SECTIONS` run — Identity through Search, every section
/// before the stack profile (#8533) — joined with a paragraph break, in block
/// order, with the trailing newline a file would have carried.
/// The paragraph break is deliberately
/// [`crate::core::instruction_package::Join::Blank`]'s literal, so this string
/// is byte-identical to what the packaged composer emits for the same blocks.
/// #8361 added the autonomy section here: it is inside this opaque blob on the
/// legacy path, so leaving it out would have dropped the rule from every
/// roster-absent prompt.
/// Test: `pm_instructions_is_the_pm_body_sections`,
/// `composed_package_is_byte_identical_to_the_legacy_bundled_fallback`.
pub(crate) fn pm_instructions(content: &FrameworkContent) -> String {
    // #8533: Identity opens the prompt and `core` was split into nine
    // sections, so the PM body is every section before the stack profile.
    let run = manifest_run(content, &PM_BODY_SECTIONS);
    format!("{run}\n")
}

/// Project a run of sections out of the bundled manifest.
///
/// Why: since #4318 the manifest may author a rule inline rather than in a
/// section file, so rebuilding these strings from the raw section files
/// would deliver that rule to package-composed sessions and silently withhold it
/// from the legacy assembly and from [`assemble_system_prompt`]. Projecting the
/// manifest keeps one source of truth for the *content*. #9012: the manifest
/// is validated when the content loads, so there is no unreadable-manifest
/// fallback to the raw section files.
/// What: [`crate::core::bundled_pm_package::authored_run`].
/// Test: `pm_instructions_is_the_pm_body_sections`, `base_pm_is_its_three_tail_sections`.
fn manifest_run(content: &FrameworkContent, sections: &[SectionId]) -> String {
    crate::core::bundled_pm_package::authored_run(content, sections)
}

/// The bundled workflow section, as authored in the manifest.
///
/// Why: the workflow section is no longer only `sections/workflow.md` — the
/// manifest appends the opportunistic-fix rule as its own block. Every consumer
/// must therefore ask the manifest, not the constant, or a project with a
/// `WORKFLOW.md` override would be the only one to notice the difference.
/// What: the authored workflow blocks joined as declared.
/// Test: `workflow_section_carries_the_opportunistic_fix_rule`,
/// `assemble_system_prompt_contains_all_sections`.
pub(crate) fn workflow_section(content: &FrameworkContent) -> String {
    manifest_run(content, &[SectionId::Workflow])
}

/// The bundled delegation doctrine plus the roster-precedence note.
///
/// Why: #4318 moved the note's prose out of a Rust constant and into the manifest,
/// where its position between the doctrine and the live roster is declared rather
/// than formatted in by hand at two call sites.
/// What: the authored `agent-delegation` blocks joined as declared — the section
/// source followed by the note — trimmed.
/// Test: `bundled_delegation_appends_deployed_roster`,
/// `composed_prompt_carries_the_live_roster_and_the_precedence_note`.
pub(crate) fn delegation_doctrine(content: &FrameworkContent) -> String {
    manifest_run(content, &[SectionId::AgentDelegation])
}

/// The raw `sections/agent-delegation.md` doctrine — the roster-free body the
/// override resolver uses when no `AGENT_DELEGATION.md` override is present.
pub(crate) fn agent_delegation(content: &FrameworkContent) -> &str {
    content.required("sections/agent-delegation.md")
}

/// The prompt tail, rebuilt from its three sections.
///
/// Why: the floor is appended last under *every* override branch, including full
/// PM replacement, so the resolver needs it as one opaque string. Same
/// no-duplicate-copy argument as [`pm_instructions`].
/// What: Enforcement (the Prohibitions and Circuit Breakers tables),
/// Non-Overridable Rules (which carries the Trusty tool-priority mandate) and
/// Framework-Guaranteed Conventions, joined with a paragraph break. All three
/// are tier `project` since #8533; Identity opens the prompt instead.
///
/// #4573: `Enforcement` joins the floor here so the two authority tables reach
/// EVERY legacy branch too — including the `PM_INSTRUCTIONS_DEPLOYED.md` full
/// replacement, which discards every body section and appends only this string.
/// That is the second of the two wholesale-deletion paths #4573 names, and it is
/// closed by this list, not by the tier declaration alone.
///
/// ORDER CHANGE, recorded because it is the one floor reordering #4183 makes:
/// `BASE_PM.md` used to place `## Trusty Tool Priority (Non-Overridable)` *after*
/// `## Framework-Guaranteed Conventions`. It is a non-overridable rule, so it now
/// travels with the other non-overridable rules and consequently precedes the
/// conventions. Position only — not one word of either block changed, and both
/// remain inside the floor, so nothing about what is overridable moved.
/// Test: `base_pm_is_its_three_tail_sections`, `floor_carries_the_tool_priority_mandate`.
pub(crate) fn base_pm(content: &FrameworkContent) -> String {
    // #8533: Identity moved to the top of the prompt, out of this tail.
    let run = manifest_run(
        content,
        &[
            SectionId::Enforcement,
            SectionId::NonOverridableRules,
            SectionId::FrameworkGuaranteedConventions,
        ],
    );
    format!("{run}\n")
}

/// Assemble the full system prompt from the content's section sources.
///
/// Why: a launched `claude` session must receive identical, version-controlled
/// PM instructions every time; joining the verified content source's sections
/// here removes any dependency on an external `~/.claude-mpm/` install.
/// What: concatenates the bundled sections in the fixed order
/// PM body → WORKFLOW → AGENT_DELEGATION → floor, separated by a `---` rule. The
/// floor comes last as the non-overridable framework floor; it carries the
/// Trusty MCP tool-priority block.
/// Test: `assemble_system_prompt_contains_all_sections`.
pub fn assemble_system_prompt(content: &FrameworkContent) -> String {
    [
        pm_instructions(content).as_str(),
        workflow_section(content).as_str(),
        agent_delegation(content),
        base_pm(content).as_str(),
    ]
    .join(SECTION_SEPARATOR)
}

/// Filename of the compiled PM system prompt.
///
/// Why (#4752): the compiled prompt needs a path NO other writer targets. It
/// previously shared `instructions/INSTRUCTIONS.md` with a bundled 4-line stub
/// and with the pipeline's own input file, so the on-disk answer to "what
/// instructions is my session running?" depended on which writer ran last —
/// the regression #383 already fixed once and #4752 closes structurally.
/// What: `INSTRUCTIONS-COMPILED.md`, resolved per SESSION by
/// [`compiled_prompt_path`] — never under the global framework root.
/// Test: `compiled_prompt_path_is_project_local`,
/// `compiled_prompt_path_is_not_the_bundled_instructions_path`.
pub const COMPILED_PROMPT_FILE: &str = "INSTRUCTIONS-COMPILED.md";

/// Resolve a session's compiled-prompt path.
///
/// Why (#4752, then #4832): the compiled prompt answers "what instructions is
/// MY session running", so it is per-SESSION output, not per-project config.
/// #4752 moved it off the shared `~/.trusty-mpm/framework/` file, where every
/// project overwrote every other; #4832 finishes the job on both remaining
/// axes. Project-scoping still let two concurrent sessions in one project
/// overwrite each other, so the file now sits under `sessions/<id>/`. And the
/// project directory a caller hands in is frequently a WORKTREE — resolving
/// against it gave one project a `.trusty-mpm/` per branch, which the owner
/// ruled out (a worktree carries code, config and docs; harness state belongs
/// to the project). [`crate::core::harness_root::session_dir`] resolves both.
///
/// This is why the accessor does NOT live on `FrameworkPaths`: that type models
/// the global framework INSTALL layout, and this file is per-session state.
/// What: `<harness-root>/.trusty-mpm/sessions/<session_id>/INSTRUCTIONS-COMPILED.md`,
/// where the harness root is the checkout that owns `project_dir` — the main
/// checkout, never a worktree of it.
/// Test: `compiled_prompt_path_is_project_local`,
/// `compiled_prompt_path_is_per_session`,
/// `compiled_prompt_path_never_lands_inside_a_worktree`.
pub fn compiled_prompt_path(project_dir: &std::path::Path, session_id: &str) -> std::path::PathBuf {
    crate::core::harness_root::session_dir(project_dir, session_id).join(COMPILED_PROMPT_FILE)
}

/// The operator-facing explanation for a failed instruction write.
///
/// Why (#4752): establishing a session's instructions is fatal — it refuses a
/// launch — so the operator must be told what was refused, where, and why, not
/// handed a bare `io::Error`. One formatter keeps that message identical
/// wherever the refusal surfaces: the CLI (`tm session start`), the daemon
/// resume path, the bare-`tm` in-place relaunch, or the HTTP client.
///
/// Renamed from `instructions_failure_message` (round 4): it now also covers
/// a [`build_instructions`] failure, so wording specific to the compiled prompt
/// would have been wrong at half its call sites.
///
/// #5228 widened it again: the stale-`CLAUDE.md` refusal declines the write on
/// purpose, so "could not write" was a false claim and the permissions remedy
/// the wrong advice. It now states the condition and defers the remedy to
/// `source`, which carries a specific one where it has it.
/// What: names the path, the underlying cause, and the remedy.
/// Test: `instructions_failure_message_names_the_path_and_a_remedy`.
pub fn instructions_failure_message(path: &std::path::Path, source: &std::io::Error) -> String {
    // See #5228.
    format!(
        "could not establish the session instructions at {}: {source}\n\
         The session was NOT started: it depends on those instructions. Resolve \
         the cause above — for a path `tm` could not write, check permissions and \
         free space — then retry.",
        path.display()
    )
}

/// Write an already-composed prompt to a project's compiled-prompt path.
///
/// Why (#4752): every writer of `INSTRUCTIONS-COMPILED.md` goes through one
/// function so the file can never hold anything but a full compiled prompt. It
/// takes the prompt the composer ALREADY built for
/// `--append-system-prompt-file` rather than recomposing it, so the launch cost
/// is one `write`, not a second composition pass.
/// What: creates `dest`'s parent directory if absent, then writes `prompt`
/// verbatim. Returns the underlying IO error unchanged; pair it with
/// [`instructions_failure_message`] when surfacing to an operator.
///
/// #7514: this write is PURE — it touches `dest` and nothing else. The
/// instruction-compression savings row used to be recorded here, against
/// [`crate::core::paths::FrameworkPaths::default`], which resolves under the
/// operator's real home. That made the operator's ledger reachable from any
/// unit test that wrote a compiled prompt into a tempdir: 228 of the 229
/// `instruction-compression` rows on the owner's host were this function's own
/// 13-byte fixture. Recording is now an explicit choice of the caller —
/// [`write_compiled_prompt_recording_in`], which records against the framework
/// root the caller already holds rather than one resolved from the process's
/// home.
/// Test: `write_compiled_prompt_to_creates_parent_dirs`,
/// `a_bare_compiled_write_records_no_savings_row`.
pub fn write_compiled_prompt_to(dest: &std::path::Path, prompt: &str) -> std::io::Result<()> {
    if let Some(parent) = dest.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(dest, prompt)
}

/// [`write_compiled_prompt_to`], then record the fold against `framework_root`.
///
/// Why (#6958, re-seamed by #7514): one choke point still knows both halves of
/// the instruction fold, so the savings row is recorded here rather than at each
/// launch path — but the LEDGER it lands in is now named by the caller instead
/// of resolved from the process's home directory. `prepare_session_inner`
/// already holds a [`crate::core::paths::FrameworkPaths`], so threading its root
/// keeps a launch prepared under a temp root recording under that same root.
/// What: the pure write, then
/// [`crate::core::savings_instructions::record_instruction_compression_in_with`].
/// Recording is best-effort by contract — a write failure returns before it, so
/// no row can describe a prompt that never landed.
/// Test: covered by construction through
/// [`write_compiled_prompt_recording_in_with`] (#7746), which
/// `a_recording_compiled_write_reaches_the_named_framework_root` exercises
/// directly.
pub(crate) fn write_compiled_prompt_recording_in(
    content: &FrameworkContent,
    framework_root: &std::path::Path,
    dest: &std::path::Path,
    prompt: &str,
) -> std::io::Result<()> {
    // #7746: delegates to the seamed variant below with the ambient roster
    // resolver, so this production entry point's observable behavior is
    // unchanged.
    write_compiled_prompt_recording_in_with(
        content,
        framework_root,
        dest,
        prompt,
        crate::core::savings_instructions::ambient_roster_source_bytes,
    )
}

/// [`write_compiled_prompt_recording_in`] with an explicit roster-byte
/// resolver (the test seam, #7746).
///
/// Why: the ambient resolver reads process-global `$CLAUDE_CONFIG_DIR` and
/// `$HOME`, which a concurrently running `RosterTiers`-guarded test mutates.
/// Injecting the resolver removes that dependence for a caller that needs a
/// deterministic source-byte count instead.
/// What: the pure write, then
/// [`crate::core::savings_instructions::record_instruction_compression_in_with`]
/// with `roster_source` threaded through.
/// Test: `a_recording_compiled_write_reaches_the_named_framework_root`.
pub(crate) fn write_compiled_prompt_recording_in_with(
    content: &FrameworkContent,
    framework_root: &std::path::Path,
    dest: &std::path::Path,
    prompt: &str,
    roster_source: impl FnOnce(&std::path::Path) -> usize,
) -> std::io::Result<()> {
    write_compiled_prompt_to(dest, prompt)?;
    crate::core::savings_instructions::record_instruction_compression_in_with(
        framework_root,
        crate::core::savings_instructions::SectionBytes::of(content),
        dest,
        prompt,
        roster_source,
    );
    Ok(())
}

/// Compose a project's prompt and refresh its compiled prompt on disk — fatally.
///
/// Why (#4752): THREE entry points hand a runtime a prompt, and each must
/// guarantee the compiled copy is current first — fresh start
/// ([`crate::core::session_launch::prepare_session`]), daemon resume
/// (`managed_routes::lifecycle::resume_managed`), and the bare-`tm` in-place
/// relaunch (`tm::commands::guided_inplace::run_inplace_relaunch`). Round 3 of
/// this issue missed the third: it calls neither of the other two, so its only
/// compiled write was `build_prompt_file`'s best-effort refresh. A resumed
/// session could therefore run on a stale or missing compiled prompt exactly
/// where a fresh launch would have refused.
///
/// SCOPE — this helper serves TWO of those three, not all three. The two
/// resume-shaped paths (daemon resume, in-place relaunch) share it because
/// neither has an explicit style to apply, so composing with `None` is right for
/// both. `prepare_session` deliberately does NOT call it: it must compose with
/// the `effective_style` it just resolved (flag > config > manifest), which this
/// helper hardcodes to `None`, and routing it through here would silently drop
/// the operator's chosen output style from the compiled copy. All three still
/// share the actual write via [`write_compiled_prompt_recording_in`] and the
/// same fatal policy; what differs is only which text they compose.
/// What: [`refresh_compiled_prompt_in`] against
/// [`crate::core::paths::FrameworkPaths::default`]'s root, which is correct for
/// both callers because both are real launches on the operator's own machine.
///
/// PRECONDITION: `session_id` must be a session SCOPE — a managed session id or
/// [`crate::core::harness_root::UNMANAGED_SESSION_SCOPE`] — because
/// [`compiled_prompt_path`] uses it verbatim as a directory name and
/// `savings_instructions::session_and_root` reads it back as the row's fallback
/// key. Both production callers pass a `ManagedSessionId`'s string form.
/// Test: `refresh_compiled_prompt_writes_the_project_local_file`,
/// `refresh_compiled_prompt_reports_an_actionable_failure`.
pub fn refresh_compiled_prompt(
    project_dir: &std::path::Path,
    session_id: &str,
) -> Result<(), String> {
    // #7514: a real launch, so the ambient framework root is the right ledger.
    let root = crate::core::paths::FrameworkPaths::default().root;
    refresh_compiled_prompt_in(&root, project_dir, session_id)
}

/// [`refresh_compiled_prompt`] against a caller-named framework root.
///
/// Why (#7514): the entry point above resolves its ledger from the process home
/// directory, so the two unit tests that drive it wrote a savings row — or a
/// `no-fold-warned` marker — into the operator's own `~/.trusty-mpm/usage/`.
/// Naming the root is what lets those tests stay on a tempdir without mutating
/// `$HOME`.
/// What: composes the project-resolved prompt through the same seam the launcher
/// uses, with no explicit output style, writes it to [`compiled_prompt_path`]
/// and records the fold against `framework_root`; on failure returns the
/// operator-facing string from [`instructions_failure_message`] — callers refuse
/// the launch with it.
/// Test: `refresh_compiled_prompt_writes_the_project_local_file`,
/// `refresh_compiled_prompt_reports_an_actionable_failure`,
/// `a_launch_from_a_checkout_whose_package_does_not_parse_is_refused`.
pub fn refresh_compiled_prompt_in(
    framework_root: &std::path::Path,
    project_dir: &std::path::Path,
    session_id: &str,
) -> Result<(), String> {
    refresh_compiled_prompt_loading(
        framework_root,
        project_dir,
        session_id,
        crate::core::content_source::framework_content_for,
    )
}

/// [`refresh_compiled_prompt_in`] with the content loader given, so a test can
/// point it at an empty cache (#9012).
///
/// What: `load(project_dir)`; on `Err` returns the error, which names its
/// remedy, and writes nothing — resume and in-place relaunch refuse on it.
/// Test: `refresh_compiled_prompt_without_content_names_tm_content_install`.
pub fn refresh_compiled_prompt_loading(
    framework_root: &std::path::Path,
    project_dir: &std::path::Path,
    session_id: &str,
    load: impl FnOnce(
        &std::path::Path,
    ) -> Result<FrameworkContent, crate::core::content_source::AgentContentError>,
) -> Result<(), String> {
    // #9012: the sections are runtime content; with none the launch is refused
    // with the remedy, never handed a prompt missing them.
    let content =
        load(project_dir).map_err(|err| format!("cannot compose the PM instructions: {err}"))?;
    refresh_compiled_prompt_with(&content, framework_root, project_dir, session_id)
}

/// [`refresh_compiled_prompt_in`] with the content already loaded (#9012).
///
/// Test: `refresh_compiled_prompt_writes_the_project_local_file`.
pub fn refresh_compiled_prompt_with(
    content: &FrameworkContent,
    framework_root: &std::path::Path,
    project_dir: &std::path::Path,
    session_id: &str,
) -> Result<(), String> {
    let native = crate::core::output_style::claude_supports_native_output_style();
    let prompt = crate::core::session_launch::build_system_prompt_for_with_style_and_native(
        content,
        project_dir,
        None,
        native,
    );
    let dest = compiled_prompt_path(project_dir, session_id);
    write_compiled_prompt_recording_in(content, framework_root, &dest, &prompt)
        .map_err(|source| instructions_failure_message(&dest, &source))
}

/// Delete a pre-#4832 per-project compiled prompt and its `framework/` dir.
///
/// Why: #4832 moved the compiled prompt from
/// `<project>/.trusty-mpm/framework/INSTRUCTIONS-COMPILED.md` to
/// `<harness-root>/.trusty-mpm/sessions/<id>/INSTRUCTIONS-COMPILED.md`. An
/// upgraded install keeps the old file forever with no writer left to refresh
/// it, and it is the file an operator inspects to answer "what is my session
/// running" — a stale answer there is worse than no answer. Every worktree of
/// an upgraded project also holds one, since the old path resolved against the
/// worktree. Nothing but tm ever wrote it, so removing it takes nothing from
/// the operator. `manifest.toml` — the one operator-authored file that now
/// lives in `framework/` — is deliberately NOT touched: the directory is only
/// removed when it is empty.
/// What: removes `<project_dir>/.trusty-mpm/framework/INSTRUCTIONS-COMPILED.md`
/// if present, and ONLY THEN removes the now-possibly-empty `framework/`
/// directory (ignoring the not-empty error). Returns whether a file was
/// removed. The gate matters because this runs on every launch while
/// `tm project init` seeds an empty `framework/`; ungated, init created the
/// directory and the next launch deleted it. Resolves against `project_dir`
/// VERBATIM, not the harness root: the point is to clean the worktree-local
/// copies the old path created.
/// Test: `migrate_removes_a_legacy_compiled_prompt`,
/// `migrate_keeps_a_sibling_manifest`,
/// `migrate_is_a_noop_when_absent`,
/// `migrate_keeps_an_empty_framework_dir_when_nothing_migrated`.
pub fn remove_legacy_compiled_prompt(project_dir: &std::path::Path) -> std::io::Result<bool> {
    let framework = project_dir
        .join(crate::core::harness_root::HARNESS_DIR)
        .join(crate::core::harness_root::FRAMEWORK_DIR);
    let removed = match std::fs::remove_file(framework.join(COMPILED_PROMPT_FILE)) {
        Ok(()) => true,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
        Err(err) => return Err(err),
    };
    // Only when this call actually migrated something. `remove_dir` refuses a
    // non-empty directory, which preserves a sibling `manifest.toml` — but it
    // does NOT distinguish "empty leftover" from "empty because `tm project
    // init` just created it", and that scaffolding runs on init while this runs
    // on every launch. Ungated, init created the directory and the next launch
    // deleted it (#4841 review).
    if removed {
        let _ = std::fs::remove_dir(&framework);
    }
    Ok(removed)
}

/// Delete a stale `instructions/INSTRUCTIONS.md` left by a pre-#4752 install.
///
/// Why: until #4752, `tm install` wrote the compiled prompt to
/// `framework/instructions/INSTRUCTIONS.md`. Nothing writes that path anymore —
/// but [`build_instructions`] still READS it as an optional framework section,
/// so an upgraded machine would keep folding a frozen copy of an OLD compiled
/// prompt into `PipelineOutput::merged` forever, with no writer left to
/// refresh it. A stale input nothing can update is worse than an absent one, and
/// the pipeline already treats absence as normal (`instructions_loaded: false`).
/// The file is framework-owned — trusty-mpm has always overwritten it on every
/// install — so removing it takes nothing from the operator.
/// What: removes `dest` if it exists; returns whether a file was removed. A
/// `NotFound` race is reported as "nothing removed", not an error, so a
/// concurrent install cannot fail this one.
/// Test: `remove_stale_bundled_instructions_deletes_a_leftover`,
/// `remove_stale_bundled_instructions_is_a_noop_when_absent`.
pub fn remove_stale_bundled_instructions(dest: &std::path::Path) -> std::io::Result<bool> {
    match std::fs::remove_file(dest) {
        Ok(()) => Ok(true),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(err) => Err(err),
    }
}

/// The CLAUDE.md stub seeded into a project on first session start.
///
/// Why: the project needs a place for project-specific notes; trusty-mpm
/// creates it exactly once and then never touches it again, so the operator
/// can edit freely (issue #2170 — trusty-mpm must never modify a target
/// project's `CLAUDE.md`).
/// What: #8533: the `Tokens:` list names every overridable [`SectionId`] and
/// the safety-core sentence names every overridable section with a core block;
/// both are checked against `SectionId::CANONICAL` and `SAFETY_CORE`.
/// Test: `the_stub_lists_exactly_the_overridable_section_tokens`.
///
/// [`SectionId`]: crate::core::instruction_package::SectionId
pub(crate) const CLAUDE_MD_STUB: &str = "# Project Instructions

<!-- trusty-mpm: created by `trusty-mpm session start` — customize for your project -->
<!-- trusty-mpm will never modify this file again after creating it. -->

## Project Context

<!-- Describe your project, tech stack, and any conventions the agent should know. -->

## Framework Instructions

This file is the ONLY surface for customizing the PM's instructions. To replace
a section of the framework prompt, put the replacement between a marker pair —
`<!-- TRUSTY-MPM: WORKFLOW START v=1 -->` on its own line, your text, then
`<!-- TRUSTY-MPM: WORKFLOW END -->` on its own line.

(Those markers are shown inline deliberately. A marker alone on a line IS the
mechanism, so a worked example here would take effect as a real override — see
`seeded_claude_md_declares_no_overrides`.)

Tokens: `IDENTITY`, `PM-ALLOWLIST`, `DELEGATION-MECHANICS`, `AGENT-ROUTING`,
`SUBAGENT-RE-ENGAGEMENT`, `PHASES`, `QA-GATE`, `GIT-FILE-TRACKING`,
`TICKETS-PRS-RELEASES`, `MESSAGES-REPORTS-SESSIONS`, `AUTONOMOUS-EXECUTION`,
`MEMORY`, `SEARCH`, `WORKFLOW`, `AGENT-DELEGATION`, `ENFORCEMENT`,
`NON-OVERRIDABLE-RULES`, `FRAMEWORK-GUARANTEED-CONVENTIONS`. `CORE` is the one
token that is always declined. An override of `MEMORY`, `SEARCH` or
`AGENT-DELEGATION` replaces that section but keeps its safety-core block: the
memory protocol, the code search protocol, agent selection and the agent roster.
`AUTONOMOUS-EXECUTION` is where a project sets how freely the PM runs — e.g.
\"ask before dispatching after a resume\" (#8361). Prose outside the markers is
project context — Claude Code loads it natively, so it is never copied into the
composed prompt.

The instructions actually delivered to this session are written to
`.trusty-mpm/last-instructions.md` on every launch. Read that file to see what
the PM received, including which of your overrides applied:

```
tm session instructions          # print it, with applied/declined markers on stderr
cat .trusty-mpm/last-instructions.md
```

## Preferences

<!-- Any agent behavior preferences specific to this project. -->
";

// ---------------------------------------------------------------------------
// CLAUDE.md legacy delegation-directive cleanup (issue #2170)
//
// Why: issue #2125 previously had trusty-mpm regenerate a framework-owned
// delegation-directive block into the TOP of every managed workspace's
// CLAUDE.md on every session start and during `prepare_session` re-runs. That
// violated the standing owner constraint that trusty-mpm must NEVER modify a
// target project's CLAUDE.md. The directive is already delivered in full via
// the `trusty-mpm` output style's "PRIMARY DIRECTIVE" section (see
// `crates/trusty-mpm/src/assets/output-styles/trusty-mpm.md` and its
// `-research` / `-teacher` variants), which Claude Code loads as the
// session's system prompt through the `outputStyle` settings key — making the
// CLAUDE.md copy redundant. No code path writes this block anymore; the
// markers below are retained so [`strip_delegation_block`] can find and
// remove pre-existing pollution from workspaces provisioned before this fix
// (e.g. `apex`).
//
// Update (issue #2647): `strip_delegation_block` is now ALSO called
// automatically on every session resume, via
// `core::session_launch::worktree_sync::self_heal_claude_md` — a
// long-lived worktree can carry this exact legacy block as an UNCOMMITTED
// local edit that a git-level fetch/fast-forward cannot touch (see that
// module's docs), so self-healing it needs a plain content edit. This is
// still NOT general CLAUDE.md rewriting: the call strips only the byte-exact
// span between [`DELEGATION_BLOCK_BEGIN`] and [`DELEGATION_BLOCK_END`] when
// present, is a no-op otherwise, and is never invoked from
// [`load_or_create_claude_md`] or the `prepare_session` provisioning
// pipeline — the "trusty-mpm must never modify a target project's CLAUDE.md"
// invariant still holds for every OTHER byte of the file.
// ---------------------------------------------------------------------------

/// Begin marker fencing the legacy framework-owned delegation-directive block
/// that trusty-mpm injected into project `CLAUDE.md` files prior to issue
/// #2170.
///
/// What: an HTML-comment marker, invisible in rendered Markdown and harmless
/// to Claude Code's raw-text reading. Used only by [`strip_delegation_block`]
/// to locate legacy pollution — nothing writes it anymore.
/// Test: `strip_delegation_block_removes_legacy_block`.
const DELEGATION_BLOCK_BEGIN: &str = "<!-- trusty-mpm:delegation-directive:begin \
(framework-owned — do not edit; regenerated on every session start, see issue #2125) -->";

/// End marker paired with [`DELEGATION_BLOCK_BEGIN`].
const DELEGATION_BLOCK_END: &str = "<!-- trusty-mpm:delegation-directive:end -->";

/// One-time cleanup helper: remove a legacy trusty-mpm delegation-directive
/// block from `content`, if present.
///
/// Why: workspaces provisioned before issue #2170 landed may still carry the
/// block trusty-mpm used to inject, fenced by [`DELEGATION_BLOCK_BEGIN`] /
/// [`DELEGATION_BLOCK_END`]. This function is still NOT called from
/// [`load_or_create_claude_md`] or the `prepare_session` provisioning
/// pipeline — trusty-mpm must never rewrite a project's `CLAUDE.md` as part
/// of normal provisioning. As of issue #2647 it IS called from one other
/// site: `core::session_launch::worktree_sync::self_heal_claude_md`, on
/// every session resume — a targeted, anchored self-heal (this exact fenced
/// span only) rather than the general rewriting the invariant above still
/// forbids. It is also available for an operator (or a future explicit,
/// opt-in cleanup command) to call on demand.
/// What: if both fence markers are present (in order), removes the span
/// between them (inclusive) plus the blank line that follows it, leaving
/// every other byte of `content` untouched. Returns `content` unchanged when
/// no fenced block is found.
/// Test: `strip_delegation_block_removes_legacy_block`,
/// `strip_delegation_block_noop_when_absent`.
pub fn strip_delegation_block(content: &str) -> String {
    match (
        content.find(DELEGATION_BLOCK_BEGIN),
        content.find(DELEGATION_BLOCK_END),
    ) {
        (Some(start), Some(end)) if end > start => {
            let end = end + DELEGATION_BLOCK_END.len();
            let remainder = content[end..].trim_start_matches('\n');
            format!("{}{}", &content[..start], remainder)
        }
        _ => content.to_string(),
    }
}

/// Inputs to the instruction merge pipeline.
///
/// Why: bundles the source locations so callers pass one value instead of
/// several loosely-related paths.
/// What: the project the roster is resolved for, and the project `CLAUDE.md`.
/// #4832 removed `framework_instructions_path` with the read that consumed it.
/// Test: every `pipeline_*` test constructs one of these.
#[derive(Debug, Clone)]
pub struct PipelineInput {
    /// The project whose agent roster this session will receive.
    ///
    /// #4588: this replaced a single `agents_dir`. Callers used to name one
    /// directory to scan, which is how the printed count came to describe a
    /// different set of agents than the PM was given. The roster is resolved
    /// from the project by
    /// [`crate::core::delegation_authority::resolve_roster`], so there is no
    /// longer a directory for a caller to get wrong.
    pub project_dir: PathBuf,
    /// Path to the project `CLAUDE.md`.
    pub claude_md_path: PathBuf,
    /// The operator's home directory, for the #7673 seed-site guard.
    ///
    /// #7673 review: injected rather than read from `dirs::home_dir()` at the
    /// guard, matching the `home` every other seam in this launch path already
    /// threads (#5544). `prepare_session_inner` passes the `host.home` it was
    /// given; `None` means "no home to compare against", which refuses nothing.
    pub home: Option<PathBuf>,
}

/// Result of a successful instruction merge.
///
/// Why: callers need the flags describing what happened (how many agents will
/// the PM get? was a stub created?) so they can report it to the operator.
/// What: per-source status flags. #4832 removed the `merged` text field with
/// the dead read that produced it — see the module docs.
/// Test: asserted by every `pipeline_*` test.
#[derive(Debug, Clone)]
pub struct PipelineOutput {
    /// How many delegatable agents were found.
    ///
    /// #5544: a FLOOR, not a total, whenever `unreadable_agent_paths` is
    /// non-empty — an agent whose file could not be read is missing from it.
    pub agent_count: usize,
    /// True if the `CLAUDE.md` stub was created during this run.
    pub claude_md_created: bool,
    /// Agent files and tier directories that exist but could not be read, and
    /// are therefore absent from `agent_count` (#5544). Empty on the healthy
    /// path; non-empty means the count understates the real roster.
    pub unreadable_agent_paths: Vec<PathBuf>,
}

impl PipelineOutput {
    /// Whether `agent_count` is the whole roster.
    ///
    /// Why (#5544): callers print `agent_count` to an operator, and a number
    /// that silently understates the roster reads exactly like one that does
    /// not. This is the check every such call site owes before presenting it.
    /// What: `true` when nothing was lost to a failed read.
    /// Test: `build_instructions_reports_an_unreadable_agent_file`.
    pub fn roster_is_complete(&self) -> bool {
        self.unreadable_agent_paths.is_empty()
    }
}

/// A failure raised while composing session launch instructions.
///
/// Why: the pipeline performs filesystem I/O (reading `CLAUDE.md`, creating
/// the stub and its parent directory); callers need a typed failure surface.
/// What: wraps the underlying IO error with the path that triggered it.
/// Test: not exercised by the happy-path tests; surfaced if a path is invalid.
#[derive(Debug)]
pub enum PipelineError {
    /// A filesystem operation failed; payload is the offending path.
    Io {
        /// The path the failed operation targeted.
        path: PathBuf,
        /// The underlying IO error.
        source: std::io::Error,
    },
}

impl fmt::Display for PipelineError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io { path, source } => {
                write!(f, "io error for {}: {source}", path.display())
            }
        }
    }
}

impl std::error::Error for PipelineError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io { source, .. } => Some(source),
        }
    }
}

/// Compose the effective session launch instructions.
///
/// Why: every CC session needs consistent framework instructions + dynamic
/// routing context. It also needs the project `CLAUDE.md` to exist (Claude
/// Code loads it natively), so this still seeds the stub as a side effect.
///
/// What: resolves the project's agent roster via
/// [`crate::core::delegation_authority::resolve_roster`] for its count, and
/// ensures the project `CLAUDE.md` exists (creating the stub if absent) purely
/// for its side effect.
///
/// #4832: this no longer reads `<framework_root>/instructions/INSTRUCTIONS.md`.
/// Nothing has written that file since #4752, its content fed only the unread
/// `PipelineOutput::merged`, and any read error other than `NotFound` became
/// the one fatal `PrepError` variant — a session refused over bytes nobody
/// used. See the module docs.
///
/// #5228: the seed is now conditional. A `CLAUDE.md` the upstream default
/// branch tracks but this working tree lacks means the branch is stale, not
/// that the project is new, so this returns `Err` — which
/// [`crate::core::session_launch::prepare_session`] treats as fatal — instead
/// of writing a stub that would stand in for every project instruction.
///
/// Test: `pipeline_full`, `pipeline_creates_claude_md`,
/// `pipeline_does_not_read_the_retired_framework_instructions`,
/// `build_instructions_refuses_a_stale_worktree_rather_than_seeding_a_stub`,
/// `session_start_count_matches_the_delivered_delegation_roster`
pub fn build_instructions(input: &PipelineInput) -> Result<PipelineOutput, PipelineError> {
    build_instructions_with_init(input, None)
}

/// [`build_instructions`] with the git-init-offer prompt seam threaded through
/// (#7673 round 3 follow-up).
///
/// Why: the owner's ruling is that tm OFFERS a real yes/no before creating a
/// git repository, never a silent decision either way. `should_init` is the
/// seam that offer lives behind — see
/// [`crate::core::claude_md_seed_git::offer_git_init`]. `None` (every caller
/// except the one CLI entry point that established a TTY) keeps this
/// byte-for-byte identical to [`build_instructions`] before this follow-up.
/// What: identical to [`build_instructions`] except
/// [`load_or_create_claude_md_with_init`] receives `should_init` instead of a
/// hard-coded `None`.
/// Test: every `pipeline_*`/`build_instructions_*` test covers the `None`
/// path (unchanged) by calling [`build_instructions`]; the `Some` path is
/// covered at the one CLI entry point that can establish a TTY, in
/// `bin/tm/commands/session/instructions_tests.rs`.
pub fn build_instructions_with_init(
    input: &PipelineInput,
    should_init: Option<&mut dyn FnMut() -> bool>,
) -> Result<PipelineOutput, PipelineError> {
    // #4588: resolved by `resolve_roster` — the SAME function that renders the
    // delegation section delivered to the PM — so `agent_count`, which
    // `tm session start` prints verbatim, cannot describe a different set of
    // agents than the PM received.
    // #5544: `_reporting` so a file that failed to read is carried out of here
    // rather than silently lowering the number `tm session start` prints.
    let roster = resolve_roster_reporting(&input.project_dir);
    let agent_count = roster.agents.len();

    // Side effect only: ensure the project CLAUDE.md stub exists so a fresh
    // workspace always has a place for project notes. Claude Code memory-loads
    // `CLAUDE.md` natively and the real launch prompt is built by
    // `resolve_pm_prompt`/`build_system_prompt_for`, so the content is read
    // back only to report whether this call created it.
    // #7673 round 3 follow-up: `None` routes through the plain
    // `load_or_create_claude_md` — the same call every test in this module
    // and every pre-follow-up caller already exercises — so adding the prompt
    // seam changes nothing observable for a caller that does not supply one.
    let (_claude_md, claude_md_created) = match should_init {
        Some(f) => load_or_create_claude_md_with_init(
            &input.claude_md_path,
            input.home.as_deref(),
            Some(f),
        )?,
        None => load_or_create_claude_md(&input.claude_md_path, input.home.as_deref())?,
    };

    Ok(PipelineOutput {
        agent_count,
        claude_md_created,
        unreadable_agent_paths: roster.unreadable,
    })
}

/// An upstream ref that tracks a file this working tree does not have (#5228).
///
/// What: the ref carrying the file (`origin/main`) and the branch checked out
/// here, both quoted back to the operator so the diagnosis names the actual
/// staleness rather than describing it in the abstract.
struct UpstreamTracked {
    /// The remote-tracking ref that has the file, e.g. `origin/main`.
    reference: String,
    /// Where this working tree sits, already rendered for the message:
    /// ``branch `fix/x` `` or `detached HEAD at 1a2b3c4`.
    position: String,
    /// True when `HEAD` is detached. `merge --ff-only` would move HEAD and
    /// drop the commit the operator deliberately checked out, so the refusal
    /// offers only the file-level remedy in that state.
    detached: bool,
}

/// Ask git whether `path` is absent only because this branch predates the
/// commit that added it upstream (#5228).
///
/// Why: `NotFound` alone cannot distinguish a genuinely new project from a
/// worktree cut before `CLAUDE.md` became tracked, and the two need opposite
/// handling — seed the stub, or refuse. git already knows: the remote-tracking
/// ref is local (no network, no fetch) and either carries the file or does not.
/// What: returns `Some` only when `path`'s directory is inside a git work tree
/// AND a candidate remote-tracking ref has a blob at `path`'s repo-relative
/// location. Candidates are the branch's own `@{upstream}` and `origin/HEAD`;
/// `origin/main` / `origin/master` are consulted ONLY when neither of those
/// resolved. Every uncertain answer is `None`, so the caller keeps its
/// pre-#5228 behaviour wherever git cannot positively confirm the staleness.
/// Ordinary launches never reach this: the caller only probes when the file is
/// already known to be missing.
/// Test: `load_or_create_claude_md_refuses_to_stub_a_branch_predating_the_tracked_file`,
/// `load_or_create_claude_md_still_seeds_when_upstream_has_no_claude_md`,
/// `load_or_create_claude_md_still_seeds_in_a_repo_with_no_remote`.
fn upstream_tracking(path: &std::path::Path) -> Option<UpstreamTracked> {
    let dir = path.parent().filter(|p| !p.as_os_str().is_empty())?;
    let name = path.file_name()?.to_str()?;
    if git_stdout(dir, &["rev-parse", "--is-inside-work-tree"])? != "true" {
        return None;
    }
    let head = git_stdout(dir, &["rev-parse", "--abbrev-ref", "HEAD"])
        .unwrap_or_else(|| "HEAD".to_string());
    let detached = head == "HEAD";
    let position = if detached {
        let short =
            git_stdout(dir, &["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| "unknown".into());
        format!("detached HEAD at {short}")
    } else {
        format!("branch `{head}`")
    };

    let tracked = git_stdout(
        dir,
        &[
            "rev-parse",
            "--abbrev-ref",
            "--symbolic-full-name",
            "@{upstream}",
        ],
    );
    // `origin/HEAD` is resolved to the branch it points at so the message names
    // `origin/main`, not a symref the operator would have to dereference.
    let default_head = git_stdout(dir, &["rev-parse", "--abbrev-ref", "origin/HEAD"]);
    // #5228 review: the hardcoded pair is a LAST RESORT, never an override. A
    // repo whose default branch is `develop` can still carry an abandoned
    // `origin/main`; consulting it when an authoritative signal already answered
    // would refuse a launch that should proceed and then point the remedy at the
    // abandoned branch.
    let guessed: &[&str] = if tracked.is_none() && default_head.is_none() {
        &["origin/main", "origin/master"]
    } else {
        &[]
    };
    let candidates = tracked
        .into_iter()
        .chain(default_head)
        .chain(guessed.iter().map(|r| (*r).to_string()));

    for reference in candidates {
        // `<rev>:./<name>` resolves `name` relative to `dir` inside the repo, so
        // this stays correct for a project that is not the repository root.
        if git_succeeds(dir, &["cat-file", "-e", &format!("{reference}:./{name}")]) {
            return Some(UpstreamTracked {
                reference,
                position,
                detached,
            });
        }
    }
    None
}

/// The operator-facing explanation for a refused stub (#5228).
///
/// Why: the refusal stops a launch, so it owes the operator the diagnosis AND
/// the command that fixes it — recovery is one fast-forward away, and a bare
/// "file missing" would send them looking for the wrong problem.
/// What: names the file, where this tree sits, the ref that has it, and the
/// recovery. A branch is offered both remedies (bring the branch current, or
/// take just the file); a detached HEAD is offered only the file-level one,
/// because `merge --ff-only` there moves HEAD and drops the commit the operator
/// deliberately checked out.
/// Test: `stale_claude_md_refusal_names_the_branch_ref_and_recovery`,
/// `stale_claude_md_refusal_offers_no_merge_on_a_detached_head`.
fn stale_claude_md_message(path: &std::path::Path, upstream: &UpstreamTracked) -> String {
    let dir = path
        .parent()
        .filter(|p| !p.as_os_str().is_empty())
        .unwrap_or(std::path::Path::new("."));
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| "CLAUDE.md".to_string());
    let UpstreamTracked {
        reference,
        position,
        detached,
    } = upstream;
    let dir = dir.display();
    let remedy = if *detached {
        format!(
            "Take the file without moving HEAD:\n  \
             git -C {dir} checkout {reference} -- {name}\n\
             (No merge is offered: a fast-forward here would move a detached \
             HEAD off the commit you checked out.)"
        )
    } else {
        format!(
            "Bring the real file in with either:\n  \
             git -C {dir} merge --ff-only {reference}\n  \
             git -C {dir} checkout {reference} -- {name}"
        )
    };
    format!(
        "{name} is absent from this working tree but tracked at {reference} — \
         {position} predates the commit that added it. No stub was written: a \
         stub would replace every project instruction with boilerplate, and once \
         on disk it is indistinguishable from an authored file to every later \
         session. {remedy}"
    )
}

/// Run `git -C <dir> <args>`, returning trimmed stdout or `None` on any failure.
fn git_stdout(dir: &std::path::Path, args: &[&str]) -> Option<String> {
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(args)
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
    if text.is_empty() { None } else { Some(text) }
}

/// Whether `git -C <dir> <args>` exited zero.
fn git_succeeds(dir: &std::path::Path, args: &[&str]) -> bool {
    std::process::Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(args)
        .output()
        .is_ok_and(|out| out.status.success())
}

/// The directory a seed at `path` would land in, or `None` when the path has no
/// usable directory component — what `--dir ""` produces (#7673 review).
fn seed_dir(path: &std::path::Path) -> Option<&std::path::Path> {
    path.parent().filter(|p| !p.as_os_str().is_empty())
}

/// A refused seed, as the fatal [`PipelineError`] this seam's callers expect.
///
/// Why: three checks can refuse one seed — the site guard, the workspace-parent
/// offer, and the #7764 pre-write re-check — and which one fired must not
/// change the shape of what the operator sees. One constructor keeps them from
/// drifting apart.
/// What: `InvalidInput` carrying [`SeedRefusal::message`] for `named`, the
/// directory the refusal is about, while the error's `path` stays the file.
/// Test: `load_or_create_claude_md_refuses_to_seed_at_the_home_directory`,
/// `a_child_repository_created_between_the_check_and_the_write_refuses_the_seed`.
fn seed_refused(
    path: &std::path::Path,
    named: &std::path::Path,
    refusal: crate::core::claude_md_seed::SeedRefusal,
) -> PipelineError {
    PipelineError::Io {
        path: path.to_path_buf(),
        source: std::io::Error::new(std::io::ErrorKind::InvalidInput, refusal.message(named)),
    }
}

/// Load `CLAUDE.md`, seeding the stub (and parent directories) if it does not
/// exist. A pre-existing `CLAUDE.md` is read back byte-identical and never
/// written to (issue #2170 — trusty-mpm must never modify a target project's
/// `CLAUDE.md`).
///
/// Why: section 5 is entirely project-owned; trusty-mpm creates the stub
/// exactly once, on first session start, so the operator always has a place
/// for project-specific notes, and never touches the file again afterward.
/// What: reads the existing file and returns it unchanged; if absent, writes
/// [`CLAUDE_MD_STUB`] (creating parent directories as needed) and returns
/// that. Returns the final content and whether this call created the file.
///
/// #5228 — absent is not the same as new. When git says the upstream default
/// branch already tracks this file, its absence here means the branch predates
/// the commit that added it, and seeding the stub would hand the session
/// boilerplate in place of the project's real instructions. That failure is
/// invisible (the session cannot tell) and PERMANENT (every later run finds a
/// file present and takes the read path), so this refuses instead. The refusal
/// is fatal by the caller's own contract — `prepare_session` maps a
/// [`PipelineError`] onto `PrepError::Instructions`, the one condition #4752
/// rules must stop a launch — and the message carries the recovery command.
/// Detection is conservative: anything git cannot answer (no git, not a work
/// tree, no candidate remote-tracking ref carries the file) falls through to
/// the ordinary stub, so a genuinely new project is unaffected. Note a missing
/// `@{upstream}` alone does NOT fall open — `origin/HEAD` is still consulted,
/// and a repo with no remote at all is what actually reaches the stub.
/// #7673 — and never ABOVE a project. A tm seed template was found at `$HOME`
/// on 2026-09-12; Claude Code loads every `CLAUDE.md` from the session cwd up to
/// the filesystem root, so that one file rode into every turn of every agent in
/// every project under the home directory. This now refuses to seed when the
/// target directory is the home directory, or sits above it — see
/// [`crate::core::claude_md_seed::refuse_seed_for`]. The refusal is an ERROR
/// arm on the same fatal path as the #5228 refusal above, never a warning that
/// writes anyway. `home` is INJECTED (it rides on [`PipelineInput::home`])
/// rather than read from the ambient environment here, matching the rest of
/// this seam (#5544).
/// Test: `pipeline_creates_claude_md`, `pipeline_claude_md_left_byte_identical`,
/// `load_or_create_claude_md_refuses_to_seed_at_the_home_directory`,
/// `load_or_create_claude_md_refuses_to_seed_above_the_home_directory`,
/// `load_or_create_claude_md_fails_closed_on_a_path_with_no_directory`,
/// `load_or_create_claude_md_seeds_in_a_subdirectory_of_a_git_repo`,
/// `load_or_create_claude_md_refuses_to_stub_a_branch_predating_the_tracked_file`,
/// `build_instructions_refuses_a_stale_worktree_rather_than_seeding_a_stub`,
/// `load_or_create_claude_md_still_seeds_when_upstream_has_no_claude_md`,
/// `load_or_create_claude_md_reads_a_present_file_in_a_git_worktree`.
fn load_or_create_claude_md(
    path: &PathBuf,
    home: Option<&std::path::Path>,
) -> Result<(String, bool), PipelineError> {
    load_or_create_claude_md_with_init(path, home, None)
}

/// [`load_or_create_claude_md`] with the git-init-offer prompt seam threaded
/// through (#7673 round 3 follow-up).
///
/// Why: the owner's ruling is that tm OFFERS to create a git repository before
/// seeding into one that has none — a real yes/no, not a silent decision. A
/// `should_init` of `None` keeps every existing caller's behavior byte-for-byte
/// unchanged (the non-interactive default this module already applied);
/// `Some` is for a CLI entry point that has established it is running on a TTY
/// and built a real confirm closure (see `bin/tm/commands/session/instructions.rs`).
/// What: identical to [`load_or_create_claude_md`] except the seed site's
/// `crate::core::claude_md_seed_git::offer_git_init` call receives `should_init`
/// instead of a hard-coded `None`.
/// #7764: the workspace-parent guard is taken TWICE — once here through
/// `offer_git_init`, which may also run `git init`, and once through
/// `crate::core::claude_md_seed_git::recheck_before_seed` immediately before the
/// write, because `upstream_tracking` runs several git subprocesses in between
/// and a child repository created in that window would otherwise be missed.
/// Test: `load_or_create_claude_md_seeds_in_a_subdirectory_of_a_git_repo` and
/// the rest of [`load_or_create_claude_md`]'s pointer list cover the `None`
/// path (unchanged); the `Some` path is covered at the CLI entry point in
/// `instructions_tests.rs`, which is the only caller that can establish a TTY,
/// and by `a_child_repository_created_between_the_check_and_the_write_refuses_the_seed`.
fn load_or_create_claude_md_with_init(
    path: &PathBuf,
    home: Option<&std::path::Path>,
    should_init: Option<&mut dyn FnMut() -> bool>,
) -> Result<(String, bool), PipelineError> {
    match std::fs::read_to_string(path) {
        Ok(text) => Ok((text, false)),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            // #7673: and the seed must never land ABOVE a project. This is an
            // ERROR arm, never a warning that then writes anyway — the whole
            // finding is that a stray seed is invisible once written.
            // The decision is made on the FILE path, so an empty or absent
            // parent — what `--dir ""` produces — fails closed inside
            // `refuse_seed_for` rather than skipping the guard.
            if let Some(refusal) = crate::core::claude_md_seed::refuse_seed_for(path, home) {
                return Err(seed_refused(path, seed_dir(path).unwrap_or(path), refusal));
            }
            // #7673 round 3 (owner ruling 2026-09-13): a marker-less, non-git
            // directory below home is still seedable, but not when it is a
            // WORKSPACE PARENT — a git repository lives beneath it, so the seed
            // would become an ancestor `CLAUDE.md` for every project under it.
            // `should_init` is threaded from the caller: `None` for every
            // call site with no prompt capability (the non-interactive
            // default — seed with no git, exactly as before this follow-up),
            // `Some` only for a CLI entry point that already established it
            // is running on a TTY.
            if let Some(dir) = seed_dir(path)
                && let Err(refusal) =
                    crate::core::claude_md_seed_git::offer_git_init(dir, home, should_init)
            {
                return Err(seed_refused(path, dir, refusal));
            }
            // #5228: a stale branch's missing CLAUDE.md must never be stubbed over.
            if let Some(upstream) = upstream_tracking(path) {
                return Err(PipelineError::Io {
                    path: path.clone(),
                    source: std::io::Error::new(
                        std::io::ErrorKind::NotFound,
                        stale_claude_md_message(path, &upstream),
                    ),
                });
            }
            if let Some(parent) = path.parent()
                && !parent.as_os_str().is_empty()
            {
                std::fs::create_dir_all(parent).map_err(|source| PipelineError::Io {
                    path: parent.to_path_buf(),
                    source,
                })?;
            }
            // #7764: every check above ran before the git subprocesses in
            // `upstream_tracking` and the directory creation here, so its answer
            // is stale by the time bytes land. A repository that appeared in
            // that window is the workspace parent #7673 refuses, so the guard is
            // re-taken as the LAST thing before the write.
            // #7764 review: `create_dir_all` above runs first, so a refusal here
            // can leave an empty directory behind. Accepted, not cleaned up: the
            // only directory this call creates is one that did not exist a
            // moment earlier, whose own scan was `Clear` by construction
            // (`scan_for_child_repo` treats a missing root as nothing to find),
            // so the recheck can refuse it only if another process creates a
            // repository inside it inside this window — and removing the
            // directory would then race that same process for its parent.
            if let Some(dir) = seed_dir(path)
                && let Err(refusal) =
                    crate::core::claude_md_seed_git::recheck_before_seed(dir, home)
            {
                return Err(seed_refused(path, dir, refusal));
            }
            std::fs::write(path, CLAUDE_MD_STUB).map_err(|source| PipelineError::Io {
                path: path.clone(),
                source,
            })?;
            Ok((CLAUDE_MD_STUB.to_string(), true))
        }
        Err(err) => Err(PipelineError::Io {
            path: path.clone(),
            source: err,
        }),
    }
}

// #4832: `merge_sections` lived here. It joined the framework `INSTRUCTIONS.md`
// text with the delegation-authority section into `PipelineOutput::merged`,
// which had zero call sites — both the field and its only producer are gone.

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