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
//! Give a dispatched writer its own worktree when the session is standing in a
//! main checkout ([ADR-0048](../../../../../../docs/adr/0048-dispatched-writers-get-a-worktree-and-the-write-boundary-is-enforced.md)).
//!
//! Why: ADR-0044 made the main checkout read-only for the PM and for every
//! agent it dispatches, and ADR-0037 made the main checkout where a session
//! runs by default. Together those two leave a dispatched writer with nowhere
//! to write: `worktree_enabled_for_project` never had a production caller, so
//! nothing ever gave an agent a tree of its own, and the agent wrote to the
//! shared one because that is the only one it had. The reported harm is three
//! sessions in one `mcp-services` checkout with branches switching under each
//! other — one commit landing on a branch belonging to a different workstream,
//! and the branch it should have landed on left empty.
//!
//! What: [`evaluate_worktree_grant`] turns an unisolated dispatch into an
//! isolated one instead of refusing it. When the PM is standing in a main
//! checkout and the dispatch [`requires_own_worktree_in_main_checkout`], this
//! returns the dispatch's own `tool_input` with `isolation: "worktree"` added,
//! which `pm_guard` prints as `hookSpecificOutput.updatedInput` — the field
//! Claude Code documents as replacing a tool's arguments before it runs. The
//! harness then creates the worktree under `.claude/worktrees/` per ADR-0036
//! and removes it on completion. Trusty-mpm still creates nothing, which is
//! what ADR-0044 decision 4 requires: this asks the owner of worktrees for one.
//!
//! **A grant, not a deny, because a deny cannot be applied by the party that
//! receives it.** A refusal reaches the PM as text and depends on the model
//! re-issuing the same dispatch with one more field. The rewrite reaches Claude
//! Code as data and is applied whether or not anything read it. The costs are
//! also not equal: a false grant costs one worktree the harness reclaims when
//! it is unchanged, while a false allow puts a second writer on a git HEAD that
//! another session is standing on.
//!
//! **Only the `Agent` tool is rewritten.** `isolation` is an `Agent` parameter;
//! `Task` is the older spelling and does not carry one, and injecting a field a
//! tool's schema rejects would turn a guarded dispatch into a failed tool call.
//! A `Task` dispatch that needs isolation is DENIED instead, and told which
//! tool can give it — see [`TASK_DENY_REASON`].
//!
//! **Fail-open arms, restated because this one differs from its neighbours.**
//! A cwd that is not a main checkout is not this rule's business, and every
//! worktree answers that way, so delegated work in a worktree is untouched. A
//! cwd that resolves to no git checkout at all is likewise untouched. What does
//! NOT fail open here is an unknown agent or an untyped dispatch: both reach
//! [`requires_own_worktree_in_main_checkout`] as indeterminate, because a custom
//! or renamed agent writing into the shared checkout is the reported defect
//! rather than a hypothetical one. Since #8547 they are REFUSED rather than
//! isolated: a dispatch with no usable type bypasses the roster and every
//! guardrail keyed on an agent name, and a guessed worktree hid that from the
//! PM. A name is known when it is bundled, deployed in any roster tier or in a
//! project `.claude/agents` between the cwd and the checkout root, or a Claude
//! Code built-in; only a name none of those define is refused. The
//! refusal names the missing or unknown type and the two fixes — a known name,
//! or an explicit `isolation: "worktree"` on the `Agent` tool, which this rule
//! leaves alone — see `pm_guard_dispatch_type`.
//!
//! **A project may decline the grant entirely (#5814).** Isolation buys
//! separation between concurrent writers and between build states. A writing or
//! documentation repo has neither, so the grant costs it agent edits stranded in
//! `.claude/worktrees/agent-<id>/` and trees left to reclaim. Such a project sets
//! `agent_worktree = false` in its committed `.trusty-mpm.toml`, which
//! [`trusty_mpm::project::dispatched_agent_worktree_enabled`] reads: the dispatch
//! then gets [`WorktreeGrant::InPlace`] — its prompt annotated with
//! `IN_PLACE_WORKFLOW_NOTE` and no `isolation` added — instead of a worktree.
//! The check runs AFTER `is_main_checkout` so only a dispatch that would
//! otherwise be granted pays for the file read, and an absent, malformed, or
//! unreadable config leaves the grant exactly as it was.
//!
//! Test: `grants_*`, `does_not_grant_*`, `refuses_*`, `task_dispatch_*`, `in_place_*` below;
//! `pm_guard_grants_a_worktree_to_a_writer_in_a_main_checkout` and siblings in
//! `tests/tm_hook_pm_guard.rs` run it through the real binary.

use std::path::Path;

use serde_json::Value;
use trusty_mpm::core::agent::is_subagent_dispatch_tool;
use trusty_mpm::core::content_source::AgentRoster;
use trusty_mpm::core::delegation_authority::deployed_agent_dirs;
use trusty_mpm::core::dispatch_isolation::{
    dispatch_agent, dispatch_isolation, requires_own_worktree_in_main_checkout,
};
use trusty_mpm::core::project_aliases::is_main_checkout;
use trusty_mpm::project::dispatched_agent_worktree_enabled;

use crate::commands::pm_guard_dispatch_type::{DeployedTiers, undetermined_type_refusal};

/// The `isolation` value granted to a dispatch that needs its own tree.
///
/// Why: the harness offers two tree-separating modes and only one of them is
/// this machine's to grant — `remote` runs the agent on different hardware,
/// which is an operator's decision about cost and connectivity, not a guard's.
/// What: the first member of
/// [`ISOLATING_DISPATCH_MODES`](trusty_mpm::core::dispatch_isolation::ISOLATING_DISPATCH_MODES),
/// asserted equal to it
/// so the two cannot drift into disagreeing about what counts as isolation.
/// Test: `granted_mode_is_recognised_as_isolating`.
const GRANTED_ISOLATION: &str = "worktree";

/// The tool whose schema documents an `isolation` parameter.
///
/// Why: see the module doc — a rewrite that adds a field the receiving tool
/// does not accept converts a guarded dispatch into a broken one.
/// What: matched case-sensitively against the payload's `tool_name`.
/// Test: `task_dispatch_is_denied_rather_than_rewritten`.
const ISOLATION_AWARE_DISPATCH_TOOL: &str = "Agent";

/// Deny text for a `Task` dispatch that needs a worktree it cannot be given.
///
/// Why: the one case this module refuses instead of fixing, so the message has
/// to carry the whole remedy — which tool to use, which field to set, and why
/// the directory is special. A constant rather than a built string because
/// nothing about it varies with the dispatch.
/// Test: `task_dispatch_is_denied_rather_than_rewritten`.
pub(crate) const TASK_DENY_REASON: &str = "Unisolated dispatch denied in a main checkout (ADR-0044): this session is standing in a \
     project's main checkout, which is read-only apart from documents and configuration, and \
     this dispatch would put an agent that may write files into it. Another session may be \
     standing in the same directory — the reported failure is a commit landing on a branch \
     belonging to a different workstream, with no error at any step. Dispatch through the \
     `Agent` tool with `isolation: \"worktree\"` instead, which gets the agent a tree of its \
     own; `Task` carries no isolation parameter, so it cannot be given one here. A read-only \
     agent (research, review, analysis) is never blocked by this rule.";

/// What an opted-out project's dispatch is told instead of being isolated.
///
/// Why (#5814): suppressing the grant is only half the fix. The agent still
/// arrives carrying the framework's own worktree discipline and, finding itself
/// in a main checkout with no tree of its own, stops rather than proceeds — the
/// reported failure is a `version-control` agent that refused to commit for
/// exactly that reason. The note states the workflow the project chose when it
/// set the flag, and it reaches the agent as data: it is appended to the
/// dispatch's own prompt through `hookSpecificOutput.updatedInput`, the same
/// mechanism the grant uses, so it applies whether or not the PM read anything.
/// What: appended verbatim, once, to a string `prompt`.
/// Test: `in_place_notice_is_appended_to_the_prompt`,
/// `in_place_notice_is_not_appended_twice`.
const IN_PLACE_WORKFLOW_NOTE: &str = "\n\n---\nWorktree isolation is OFF for this project: it sets `agent_worktree = false` in \
     `.trusty-mpm.toml` (trusty-tools#5814). No worktree has been created for you and none will \
     be reclaimed, so work IN PLACE in the current working directory — edit the files where they \
     are, commit on the branch the checkout already has, and push. Do not create a worktree, a \
     branch, or a pull request unless this project's own instructions ask for one. The \
     main-checkout write boundary is unchanged: a source-file edit here is still denied.";

/// Decide what to do with one dispatch made from a main checkout.
///
/// Why: the single entry point `pm_guard` calls, kept as one function so the
/// ordering — cheapest test first, filesystem last — cannot be re-derived
/// differently at the call site.
/// What: `None` (nothing to do) for every non-dispatch tool, for a dispatch
/// that already declares isolation, for a positively-identified read-only
/// agent, and for any cwd that is not a main checkout. Otherwise
/// [`WorktreeGrant::Rewrite`] carrying the dispatch's own input with
/// `isolation` added, [`WorktreeGrant::Deny`] when the tool cannot accept one
/// or the dispatch's agent type is missing, unparsable or unknown (#8547), or
/// [`WorktreeGrant::InPlace`] when the project declined isolation (#5814).
///
/// `is_main_checkout` is tested LAST because it is the only branch that touches
/// the filesystem, and every ordinary tool call must not pay for it. The #5814
/// project opt-out reads a second file and therefore sits behind it.
/// Test: `grants_a_worktree_to_an_unisolated_writer`,
/// `does_not_grant_outside_a_main_checkout`, `does_not_grant_a_read_only_agent`,
/// `grants_nothing_when_the_project_opts_out`.
pub(crate) fn evaluate_worktree_grant(
    roster: &AgentRoster,
    tool_name: &str,
    tool_input: Option<&Value>,
    cwd: &Path,
) -> Option<WorktreeGrant> {
    evaluate_worktree_grant_with(roster, tool_name, tool_input, cwd, &deployed_agent_dirs)
}

/// [`evaluate_worktree_grant`] with the roster tiers injected.
///
/// Why: `deployed_agent_dirs` reads `CLAUDE_CONFIG_DIR` and `$HOME`, which a
/// test in this target may not write; a hermetic test passes its own tiers.
/// Test: `a_project_agent_is_known_from_a_subdirectory_of_the_checkout`.
pub(crate) fn evaluate_worktree_grant_with(
    roster: &AgentRoster,
    tool_name: &str,
    tool_input: Option<&Value>,
    cwd: &Path,
    deployed: DeployedTiers<'_>,
) -> Option<WorktreeGrant> {
    if !is_subagent_dispatch_tool(tool_name) {
        return None;
    }
    let agent = dispatch_agent(tool_input).unwrap_or_default();
    if !requires_own_worktree_in_main_checkout(Some(roster), agent, dispatch_isolation(tool_input))
    {
        return None;
    }
    if !is_main_checkout(cwd) {
        return None;
    }
    // #5814: a project with no concurrent writers and no build state declares
    // `agent_worktree = false`, and its agents work in the checkout instead.
    if !dispatched_agent_worktree_enabled(cwd) {
        return with_in_place_notice(tool_input).map(WorktreeGrant::InPlace);
    }
    let accepts_isolation = tool_name == ISOLATION_AWARE_DISPATCH_TOOL;
    // #8547: refuse, never isolate, a dispatch whose type is missing or unknown.
    if let Some(reason) =
        undetermined_type_refusal(roster, tool_input, cwd, accepts_isolation, deployed)
    {
        return Some(WorktreeGrant::Deny(reason));
    }
    if !accepts_isolation {
        return Some(WorktreeGrant::Deny(TASK_DENY_REASON.to_string()));
    }
    Some(WorktreeGrant::Rewrite(with_granted_isolation(tool_input)))
}

/// What [`evaluate_worktree_grant`] decided.
///
/// Why: the outcomes are different JSON objects on the hook's stdout — two
/// replace the tool's arguments, one blocks the call — and a `PreToolUse` hook
/// may print exactly one of them, so the choice has to be explicit rather than
/// inferred from an `Option` at the call site.
/// Test: `task_dispatch_is_denied_rather_than_rewritten`,
/// `grants_nothing_when_the_project_opts_out`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WorktreeGrant {
    /// Replace the dispatch's arguments with these, isolation included.
    Rewrite(Value),
    /// Block the dispatch: the tool cannot be given isolation, or the
    /// dispatch names no agent type this binary can classify (#8547).
    Deny(String),
    /// The project declined isolation (#5814): these arguments carry the
    /// original input plus the in-place workflow note, and NO `isolation`.
    ///
    /// It is a separate variant from [`Self::Rewrite`] because the call site
    /// must not report a granted worktree to the daemon for a dispatch that was
    /// given none — the delegation record would then name a tree that does not
    /// exist. The concurrency question is still asked; see the call site.
    InPlace(Value),
}

/// The dispatch's own input with `isolation` set to [`GRANTED_ISOLATION`].
///
/// Why: `updatedInput` REPLACES a tool's arguments rather than merging into
/// them, so the rewrite has to carry every field the caller sent — dropping the
/// prompt would dispatch an agent with no brief.
/// What: clones `tool_input` and inserts `isolation`. A missing or non-object
/// input yields an object holding `isolation` alone: there is nothing to
/// preserve, and the dispatch was already malformed by the time it got here.
/// Test: `rewrite_preserves_every_field_the_caller_sent`.
fn with_granted_isolation(tool_input: Option<&Value>) -> Value {
    let mut input = match tool_input {
        Some(Value::Object(map)) => Value::Object(map.clone()),
        _ => Value::Object(serde_json::Map::new()),
    };
    input["isolation"] = Value::String(GRANTED_ISOLATION.to_string());
    input
}

/// The dispatch's own input with [`IN_PLACE_WORKFLOW_NOTE`] appended to its prompt.
///
/// Why (#5814): `updatedInput` REPLACES a tool's arguments, so the whole input
/// is carried and only the prompt changes — above all, `isolation` is NOT added,
/// which is the entire point of the opt-out.
/// What: `Some(input)` when the dispatch carries a string `prompt` that does not
/// already end with the note; `None` otherwise, which leaves the dispatch
/// untouched and unisolated. `None` covers both a dispatch with no prompt to
/// annotate — there is nothing to say it to, and inventing a field the caller
/// did not send is how a malformed rewrite gets built — and a re-evaluated
/// dispatch, which must not accumulate a second copy of the note.
/// Test: `in_place_notice_is_appended_to_the_prompt`,
/// `in_place_notice_is_not_appended_twice`,
/// `in_place_leaves_a_promptless_dispatch_alone`.
fn with_in_place_notice(tool_input: Option<&Value>) -> Option<Value> {
    let Some(Value::Object(map)) = tool_input else {
        return None;
    };
    let prompt = map.get("prompt").and_then(Value::as_str)?;
    if prompt.contains(IN_PLACE_WORKFLOW_NOTE) {
        return None;
    }
    let mut input = Value::Object(map.clone());
    input["prompt"] = Value::String(format!("{prompt}{IN_PLACE_WORKFLOW_NOTE}"));
    Some(input)
}

/// Build the `hookSpecificOutput.updatedInput` body for a granted dispatch.
///
/// Why: mirrors [`crate::commands::hook_rewrite::build_pretooluse_rewrite_response`]
/// — the same field, confirmed against the same Claude Code hooks reference —
/// rather than reusing it, because that one is shaped for a Bash command string
/// and this one carries a whole tool input.
/// What: `{"hookSpecificOutput": {"hookEventName": "PreToolUse",
/// "updatedInput": <input>}}`. The #5814 in-place rewrite prints through here
/// too — the envelope is the same, only the input differs.
/// Test: `grant_response_has_the_documented_shape`.
pub(crate) fn build_worktree_grant_response(updated_input: &Value) -> String {
    serde_json::json!({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "updatedInput": updated_input,
        }
    })
    .to_string()
}

/// Add `additionalContext` to an already-rendered allow response (#8261).
///
/// Why: a `PreToolUse` hook's stdout may carry exactly ONE object, so the
/// agent-cost notice cannot be a second `println!` beside a build-lease
/// rewrite — it has to be merged into the same `hookSpecificOutput` that
/// already carries `updatedInput`. (#8261 round 5: no dispatch-time slot
/// notice exists; a build learns its slot from `tm build-lease`.) It lives here
/// rather than in `pm_guard.rs` because that file sits at its 500-SLOC cap.
/// What: parses `response`, inserts `additionalContext` into its
/// `hookSpecificOutput`, and re-renders. `None` — or a response this does not
/// recognise — returns the input unchanged, so a parse failure degrades to
/// today's answer instead of emitting something malformed.
/// Test: `an_agent_cost_notice_is_merged_into_the_one_hook_output_object`,
/// `an_absent_notice_leaves_the_response_byte_identical`.
pub(crate) fn with_additional_context(response: &str, context: Option<&str>) -> String {
    let Some(context) = context else {
        return response.to_string();
    };
    let Ok(mut parsed) = serde_json::from_str::<Value>(response) else {
        return response.to_string();
    };
    let Some(output) = parsed
        .get_mut("hookSpecificOutput")
        .and_then(Value::as_object_mut)
    else {
        return response.to_string();
    };
    output.insert(
        "additionalContext".to_string(),
        Value::String(context.to_string()),
    );
    parsed.to_string()
}

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

    /// The checkout roster (#9011) every classifier in this suite reads.
    fn r() -> &'static trusty_mpm::core::content_source::AgentRoster {
        crate::commands::install::test_roster_ref()
    }
    use tempfile::TempDir;
    use trusty_mpm::core::dispatch_isolation::ISOLATING_DISPATCH_MODES;

    /// A directory that answers `is_main_checkout` — a `.git` DIRECTORY, which
    /// is how git marks a main checkout and never a linked worktree.
    fn main_checkout() -> TempDir {
        let dir = tempfile::tempdir().expect("tempdir");
        std::fs::create_dir(dir.path().join(".git")).expect("mkdir .git");
        dir
    }

    fn input(agent: &str, isolation: Option<&str>) -> Value {
        match isolation {
            Some(i) => serde_json::json!({"subagent_type": agent, "isolation": i}),
            None => serde_json::json!({"subagent_type": agent}),
        }
    }

    #[test]
    fn granted_mode_is_recognised_as_isolating() {
        // The grant is worthless if the mode it injects is not one the sibling
        // classifier accepts — the dispatch would be rewritten and then denied.
        assert_eq!(ISOLATING_DISPATCH_MODES[0], GRANTED_ISOLATION);
        assert!(
            trusty_mpm::core::dispatch_isolation::isolation_separates_working_tree(Some(
                GRANTED_ISOLATION
            ))
        );
    }

    #[test]
    fn grants_a_worktree_to_an_unisolated_writer() {
        let dir = main_checkout();
        let grant = evaluate_worktree_grant(
            r(),
            "Agent",
            Some(&input("rust-engineer", None)),
            dir.path(),
        )
        .expect("a writer in a main checkout must be granted a worktree");
        let Some(WorktreeGrant::Rewrite(updated)) = Some(grant) else {
            panic!("expected a rewrite");
        };
        assert_eq!(updated["isolation"], "worktree");
        assert_eq!(updated["subagent_type"], "rust-engineer");
    }

    /// The `isolation` a dispatch of `agent` from `dir` was granted.
    fn granted_isolation(agent: &str, dir: &Path) -> Value {
        match evaluate_worktree_grant(r(), "Agent", Some(&input(agent, None)), dir) {
            Some(WorktreeGrant::Rewrite(updated)) => updated["isolation"].clone(),
            other => panic!("{agent} must be granted a worktree, got {other:?}"),
        }
    }

    #[test]
    fn grants_a_worktree_to_a_deployed_agent_tm_does_not_bundle() {
        // #8547 review: an agent deployed into a roster tier is a known name,
        // so it keeps its pre-#8547 worktree instead of being refused.
        let dir = main_checkout();
        let agents = dir.path().join(".claude/agents");
        std::fs::create_dir_all(&agents).expect("mkdir agents");
        std::fs::write(
            agents.join("fixture-deployed-ops.md"),
            "---\nname: fixture-deployed-ops\nrole: ops\n---\n\n# Ops\n",
        )
        .expect("write agent");
        assert_eq!(
            granted_isolation("fixture-deployed-ops", dir.path()),
            "worktree"
        );
        // A name defined nowhere is still refused.
        let reason = refusal(Some(&input("fixture-defined-nowhere", None)), dir.path());
        assert!(reason.contains("`fixture-defined-nowhere`"), "{reason}");
    }

    #[test]
    fn grants_a_worktree_to_a_harness_builtin_writer() {
        // #8547 review: Claude Code's own writers ship in no bundle and no tier,
        // yet they are known names — isolated, never refused.
        let dir = main_checkout();
        for agent in ["general-purpose", "claude", "statusline-setup"] {
            assert_eq!(granted_isolation(agent, dir.path()), "worktree", "{agent}");
        }
    }

    /// The deny reason for `tool_input`, panicking on any other outcome.
    fn refusal(tool_input: Option<&Value>, dir: &Path) -> String {
        match evaluate_worktree_grant(r(), "Agent", tool_input, dir) {
            Some(WorktreeGrant::Deny(reason)) => reason,
            other => panic!("{tool_input:?} must be refused, got {other:?}"),
        }
    }

    #[test]
    fn refuses_an_untyped_dispatch_instead_of_isolating_it() {
        // #8547: no `subagent_type` bypasses the roster; a guessed worktree
        // hid that from the PM. The refusal names the gap and the fix.
        let dir = main_checkout();
        for sent in [Some(serde_json::json!({"prompt": "go"})), None] {
            let reason = refusal(sent.as_ref(), dir.path());
            assert!(reason.contains("no `subagent_type`"), "{reason}");
            assert!(reason.contains("Set `subagent_type`"), "{reason}");
            assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
        }
    }

    #[test]
    fn refuses_an_unparsable_agent_type() {
        // #8547 Fail-Open Check: a type field that is present but not a usable
        // name must be refused, never allowed or guessed at.
        let dir = main_checkout();
        for raw in [
            serde_json::json!(42),
            serde_json::json!(null),
            serde_json::json!(""),
            serde_json::json!(["rust-engineer"]),
        ] {
            let sent = serde_json::json!({"subagent_type": raw, "prompt": "go"});
            let reason = refusal(Some(&sent), dir.path());
            assert!(reason.contains("not an agent name"), "{raw}: {reason}");
        }
    }

    #[test]
    fn refuses_an_unknown_agent_name() {
        // #8547: a custom or mis-cased name is indeterminate to every
        // name-based classifier, so it is refused and named.
        let dir = main_checkout();
        for agent in ["some-project-custom-agent", "Rust-Engineer"] {
            let reason = refusal(Some(&input(agent, None)), dir.path());
            assert!(reason.contains(&format!("`{agent}`")), "{reason}");
            assert!(reason.contains("not a bundled agent"), "{reason}");
        }
        // #8547 review: `Task` cannot carry `isolation`, so it is pointed at
        // the `Agent` tool rather than told to declare a field it lacks.
        let task = evaluate_worktree_grant(
            r(),
            "Task",
            Some(&input("some-project-custom-agent", None)),
            dir.path(),
        );
        let Some(WorktreeGrant::Deny(reason)) = task else {
            panic!("an unknown Task dispatch must be refused, got {task:?}");
        };
        assert!(reason.contains("`some-project-custom-agent`"), "{reason}");
        assert!(reason.contains("through the `Agent` tool"), "{reason}");
        // The same name with explicit isolation is the documented fix.
        assert_eq!(
            evaluate_worktree_grant(
                r(),
                "Agent",
                Some(&input("some-project-custom-agent", Some("worktree"))),
                dir.path()
            ),
            None
        );
    }

    #[test]
    fn does_not_grant_a_read_only_agent() {
        // A positively-identified reader writes nothing, so a worktree would be
        // pure cost — and #3455 is an open complaint about exactly that cost.
        let dir = main_checkout();
        for agent in [
            "research",
            "code-critic",
            "code-analyzer",
            "ticketing",
            "Explore",
            "Plan",
            "claude-code-guide",
        ] {
            assert_eq!(
                evaluate_worktree_grant(r(), "Agent", Some(&input(agent, None)), dir.path()),
                None,
                "{agent} only reads and must not be moved"
            );
        }
    }

    #[test]
    fn does_not_grant_a_worktree_to_version_control() {
        // ADR-0056, owner ruling 2026-08-31: the grant is what forced the
        // isolation workaround. A `version-control` agent rewritten into a
        // worktree cannot merge into main and cannot remove the tree it is
        // standing in, and the fences hid a branch ref badly enough that the
        // push had to be issued by SHA.
        let dir = main_checkout();
        assert_eq!(
            evaluate_worktree_grant(
                r(),
                "Agent",
                Some(&input("version-control", None)),
                dir.path()
            ),
            None,
            "version-control merges into main and must not be moved out of it"
        );
    }

    #[test]
    fn does_not_grant_an_already_isolated_dispatch() {
        // Both modes separate the tree; neither may be overwritten, least of
        // all `remote`, which the guard has no business downgrading.
        let dir = main_checkout();
        for mode in ["worktree", "remote"] {
            assert_eq!(
                evaluate_worktree_grant(
                    r(),
                    "Agent",
                    Some(&input("rust-engineer", Some(mode))),
                    dir.path()
                ),
                None,
                "isolation={mode} is already a tree of its own"
            );
        }
    }

    #[test]
    fn does_not_grant_outside_a_main_checkout() {
        // Delegated work in a worktree is where work is supposed to happen and
        // must be completely untouched by this rule. A non-repository directory
        // is not a checkout at all.
        let worktree = tempfile::tempdir().expect("tempdir");
        std::fs::write(worktree.path().join(".git"), "gitdir: /elsewhere").expect("write .git");
        assert_eq!(
            evaluate_worktree_grant(
                r(),
                "Agent",
                Some(&input("rust-engineer", None)),
                worktree.path()
            ),
            None
        );

        let plain = tempfile::tempdir().expect("tempdir");
        assert_eq!(
            evaluate_worktree_grant(
                r(),
                "Agent",
                Some(&input("rust-engineer", None)),
                plain.path()
            ),
            None
        );
    }

    #[test]
    fn does_not_touch_a_non_dispatch_tool() {
        let dir = main_checkout();
        for tool in ["Read", "Edit", "Write", "Bash", "SendMessage"] {
            assert_eq!(
                evaluate_worktree_grant(r(), tool, Some(&input("rust-engineer", None)), dir.path()),
                None,
                "{tool} is not a dispatch"
            );
        }
    }

    #[test]
    fn task_dispatch_is_denied_rather_than_rewritten() {
        // `Task` has no `isolation` parameter, so a rewrite would produce a
        // failed tool call rather than an isolated agent.
        let dir = main_checkout();
        let grant =
            evaluate_worktree_grant(r(), "Task", Some(&input("rust-engineer", None)), dir.path())
                .expect("a Task writer in a main checkout must be handled");
        assert_eq!(grant, WorktreeGrant::Deny(TASK_DENY_REASON.to_string()));
        assert!(TASK_DENY_REASON.contains("Agent"));
        assert!(TASK_DENY_REASON.contains(r#"isolation: "worktree""#));
    }

    #[test]
    fn rewrite_preserves_every_field_the_caller_sent() {
        // `updatedInput` replaces the arguments outright — a dropped prompt
        // would dispatch an agent with no brief.
        let original = serde_json::json!({
            "subagent_type": "rust-engineer",
            "prompt": "do the thing",
            "description": "short label",
            "model": "sonnet",
        });
        let updated = with_granted_isolation(Some(&original));
        assert_eq!(updated["prompt"], "do the thing");
        assert_eq!(updated["description"], "short label");
        assert_eq!(updated["model"], "sonnet");
        assert_eq!(updated["subagent_type"], "rust-engineer");
        assert_eq!(updated["isolation"], "worktree");
    }

    /// A project directory that is a main checkout AND carries a project config.
    ///
    /// Why: the opt-out is only reachable past `is_main_checkout`, so every
    /// #5814 case needs both the `.git` directory and the config file.
    fn main_checkout_with_config(body: &str) -> TempDir {
        let dir = main_checkout();
        std::fs::write(
            dir.path()
                .join(trusty_mpm::core::project_config::PROJECT_CONFIG_FILE),
            body,
        )
        .expect("write project config");
        dir
    }

    #[test]
    fn grants_nothing_when_the_project_opts_out() {
        // #5814: `agent_worktree = false` — the writer stays in the checkout,
        // and the rewrite carries no `isolation` at all.
        let dir = main_checkout_with_config("agent_worktree = false\n");
        let sent = serde_json::json!({"subagent_type": "rust-engineer", "prompt": "do the thing"});
        let grant = evaluate_worktree_grant(r(), "Agent", Some(&sent), dir.path())
            .expect("an opted-out project must still annotate the dispatch");
        let WorktreeGrant::InPlace(updated) = grant else {
            panic!("an opted-out project must not be granted a worktree");
        };
        assert!(
            updated.get("isolation").is_none(),
            "the opt-out must not add isolation: {updated}"
        );
        assert_eq!(updated["subagent_type"], "rust-engineer");
    }

    #[test]
    fn opt_out_covers_task_which_would_otherwise_be_denied() {
        // A `Task` writer is denied today only because it needs a worktree it
        // cannot be given. A project that needs no worktree has no such dispatch
        // to refuse.
        let dir = main_checkout_with_config("agent_worktree = false\n");
        let sent = serde_json::json!({"subagent_type": "rust-engineer", "prompt": "go"});
        assert!(
            matches!(
                evaluate_worktree_grant(r(), "Task", Some(&sent), dir.path()),
                Some(WorktreeGrant::InPlace(_))
            ),
            "the opt-out must reach Task before the deny"
        );
    }

    #[test]
    fn grants_a_worktree_when_the_project_says_nothing_or_says_yes() {
        // The default branch, which is every project that predates #5814: an
        // absent key, an absent file, an unrelated key, and a config that cannot
        // be parsed at all must ALL leave ADR-0048's grant exactly as it was.
        for body in [
            "default_model = \"opus\"\n",
            "agent_worktree = true\n",
            "worktree = false\n",
            "agent_worktre = false\n",
            "agent_worktree = \"false\"\n",
            "agent_worktree = false\nnot toml at all(",
        ] {
            let dir = main_checkout_with_config(body);
            let sent = serde_json::json!({"subagent_type": "rust-engineer", "prompt": "go"});
            let grant = evaluate_worktree_grant(r(), "Agent", Some(&sent), dir.path())
                .expect("a writer in a main checkout is still granted a worktree");
            let WorktreeGrant::Rewrite(updated) = grant else {
                panic!("expected the ordinary grant for config: {body}");
            };
            assert_eq!(updated["isolation"], "worktree", "config was: {body}");
        }
    }

    #[test]
    fn in_place_notice_is_appended_to_the_prompt() {
        // The note is how the agent learns the workflow that replaces isolation;
        // the original brief must survive in front of it.
        let sent =
            serde_json::json!({"subagent_type": "documentation", "prompt": "write the memo"});
        let updated = with_in_place_notice(Some(&sent)).expect("a prompted dispatch is annotated");
        let prompt = updated["prompt"].as_str().expect("prompt stays a string");
        assert!(prompt.starts_with("write the memo"));
        assert!(prompt.ends_with(IN_PLACE_WORKFLOW_NOTE));
        assert!(prompt.contains("agent_worktree = false"));
        assert_eq!(updated["subagent_type"], "documentation");
    }

    #[test]
    fn in_place_notice_is_not_appended_twice() {
        let sent = serde_json::json!({"prompt": format!("brief{IN_PLACE_WORKFLOW_NOTE}")});
        assert_eq!(with_in_place_notice(Some(&sent)), None);
    }

    #[test]
    fn in_place_leaves_a_promptless_dispatch_alone() {
        // Nothing to annotate, and inventing a `prompt` the caller never sent
        // would rewrite the dispatch into one it did not make.
        assert_eq!(with_in_place_notice(None), None);
        assert_eq!(
            with_in_place_notice(Some(&serde_json::json!({"subagent_type": "documentation"}))),
            None
        );
        assert_eq!(
            with_in_place_notice(Some(&serde_json::json!({"prompt": 7}))),
            None
        );
    }

    #[test]
    fn grant_response_has_the_documented_shape() {
        let body = build_worktree_grant_response(&serde_json::json!({"isolation": "worktree"}));
        let parsed: Value = serde_json::from_str(&body).expect("valid JSON");
        assert_eq!(parsed["hookSpecificOutput"]["hookEventName"], "PreToolUse");
        assert_eq!(
            parsed["hookSpecificOutput"]["updatedInput"]["isolation"],
            "worktree"
        );
        // A rewrite must never carry a permission decision: it changes the
        // arguments and leaves the permission flow alone.
        assert!(
            parsed["hookSpecificOutput"]
                .get("permissionDecision")
                .is_none()
        );
    }

    /// #8261 round 6 (critic LOW): renamed from
    /// `a_slot_notice_is_merged_into_the_one_hook_output_object` — round 5
    /// removed the dispatch-time slot notice entirely, so the only context
    /// this function ever merges today is the agent-cost notice. The MERGE
    /// mechanism this test proves is unchanged; only the example content
    /// moved to match what actually calls it.
    ///
    /// A `PreToolUse` hook's stdout may carry exactly one object, so a second
    /// printed object would be dropped or misread — and the `updatedInput`
    /// the grant already carries must survive the merge.
    #[test]
    fn an_agent_cost_notice_is_merged_into_the_one_hook_output_object() {
        let grant = build_worktree_grant_response(&serde_json::json!({"isolation": "worktree"}));
        let merged = with_additional_context(&grant, Some("this build costs an extra 3 min"));

        let parsed: Value = serde_json::from_str(&merged).expect("valid JSON");
        assert_eq!(
            parsed["hookSpecificOutput"]["additionalContext"],
            "this build costs an extra 3 min"
        );
        assert_eq!(
            parsed["hookSpecificOutput"]["updatedInput"]["isolation"], "worktree",
            "the merge must not drop the rewrite the grant already carried"
        );
        assert_eq!(
            parsed.as_object().map(serde_json::Map::len),
            Some(1),
            "exactly one top-level key: one object reaches stdout, never two"
        );
    }

    /// No notice must leave the answer exactly as it was, so a dispatch with no
    /// slot is byte-identical to its pre-#8261 output.
    #[test]
    fn an_absent_notice_leaves_the_response_byte_identical() {
        let grant = build_worktree_grant_response(&serde_json::json!({"isolation": "worktree"}));
        assert_eq!(with_additional_context(&grant, None), grant);
    }
}