Skip to main content

agentlink_domain/
plan.rs

1//! The capability lattice: deciding what, if anything, to do.
2//!
3//! Planning is a pure function of observable state — the canonical layout, the
4//! provider manifests, what the filesystem currently holds, what the lock says we
5//! own, and which link primitives the host permits. Nothing is written here.
6//! `agentlink status` renders a plan; `agentlink apply` renders the same plan and
7//! then executes it. That symmetry is what makes the tool predictable.
8//!
9//! The rule the planner exists to enforce: **never destroy content agentlink did
10//! not create.** Every ambiguous situation resolves to a [`Blocked`] outcome that
11//! names the exact command to run, rather than to a guess.
12
13use crate::layout::Layout;
14use crate::lock::Lock;
15use crate::model::{Entry, LinkSupport, LinkTarget, NodeKind, ResourceKind, Strategy, Via};
16use crate::path::RelPath;
17use crate::provider::{Capability, Provider};
18use crate::workspace::{FsResult, Workspace};
19
20/// What the planner decided for one provider/resource pair.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Outcome {
23    /// The tool reads the canonical path directly. Nothing to do, now or ever.
24    Native,
25    /// Already materialised correctly.
26    UpToDate { via: Via },
27    /// Nothing at the target: create it.
28    Create { via: Via },
29    /// An `import` stub we own exists but its contents are stale.
30    Rewrite { via: Via },
31    /// A link we own points somewhere else: repoint it.
32    Relink { via: Via, current: LinkTarget },
33    /// The target holds the only copy of this content: move it into the canonical
34    /// location, then link back. This is the onboarding path for an existing repo.
35    Adopt { via: Via },
36    /// Nothing to do, for a benign reason.
37    Skip(Skip),
38    /// Needs a human decision. Never resolved by guessing.
39    Blocked(Blocked),
40}
41
42/// Benign reasons for inaction.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Skip {
45    /// The canonical resource does not exist yet, so there is nothing to share.
46    CanonicalMissing,
47}
48
49/// Situations that require the user to choose.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum Blocked {
52    /// The target holds real content and the canonical location is free.
53    /// Running `agentlink adopt` would move it and link back.
54    NeedsAdopt,
55    /// Both the target and the canonical location hold content. Only a human can
56    /// decide how to merge them.
57    TargetOccupied,
58    /// A link exists that agentlink did not create, pointing somewhere else.
59    ForeignLink { current: LinkTarget },
60    /// The host cannot create the required link and the provider declares no
61    /// fallback.
62    Unsupported { node: NodeKind },
63}
64
65/// One decision.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Step {
68    pub provider_id: String,
69    pub provider_name: String,
70    pub resource: ResourceKind,
71    pub canonical: RelPath,
72    pub target: RelPath,
73    pub outcome: Outcome,
74    pub note: Option<String>,
75    /// Exact bytes to write when this step materialises an `import` stub.
76    ///
77    /// Rendered during planning so that a [`Step`] fully describes its own
78    /// execution: the executor needs nothing but the plan, which keeps `status`
79    /// and `apply` provably in agreement about what will happen.
80    pub import_body: Option<String>,
81}
82
83impl Step {
84    /// Whether executing this step writes to the filesystem.
85    pub fn is_write(&self) -> bool {
86        matches!(
87            self.outcome,
88            Outcome::Create { .. }
89                | Outcome::Rewrite { .. }
90                | Outcome::Relink { .. }
91                | Outcome::Adopt { .. }
92        )
93    }
94
95    pub fn is_blocked(&self) -> bool {
96        matches!(self.outcome, Outcome::Blocked(_))
97    }
98}
99
100/// A full set of decisions for a workspace.
101#[derive(Debug, Clone, Default, PartialEq, Eq)]
102pub struct Plan {
103    pub steps: Vec<Step>,
104}
105
106impl Plan {
107    /// Steps that would write to disk.
108    pub fn writes(&self) -> impl Iterator<Item = &Step> {
109        self.steps.iter().filter(|step| step.is_write())
110    }
111
112    /// Steps needing a human decision.
113    pub fn blocked(&self) -> impl Iterator<Item = &Step> {
114        self.steps.iter().filter(|step| step.is_blocked())
115    }
116
117    /// How many capabilities require no work at all — the number this project
118    /// exists to maximise.
119    pub fn free(&self) -> usize {
120        self.steps
121            .iter()
122            .filter(|step| matches!(step.outcome, Outcome::Native | Outcome::UpToDate { .. }))
123            .count()
124    }
125
126    /// How many capabilities are served by a real filesystem link, and therefore
127    /// propagate edits, renames and deletions with no further action.
128    pub fn linked(&self) -> usize {
129        self.steps
130            .iter()
131            .filter(|step| match step.outcome {
132                Outcome::UpToDate { via }
133                | Outcome::Create { via }
134                | Outcome::Relink { via, .. }
135                | Outcome::Adopt { via } => via.is_link(),
136                _ => false,
137            })
138            .count()
139    }
140
141    pub fn is_clean(&self) -> bool {
142        self.steps
143            .iter()
144            .all(|step| !step.is_write() && !step.is_blocked())
145    }
146}
147
148/// Decides what to do, given the world as it is.
149#[derive(Debug)]
150pub struct Planner<'a> {
151    layout: &'a Layout,
152    lock: &'a Lock,
153    support: LinkSupport,
154    adopt: bool,
155}
156
157impl<'a> Planner<'a> {
158    pub fn new(layout: &'a Layout, lock: &'a Lock, support: LinkSupport) -> Self {
159        Self {
160            layout,
161            lock,
162            support,
163            adopt: false,
164        }
165    }
166
167    /// Permits moving user content from a provider path into the canonical
168    /// location. Off by default: adoption is the one operation that relocates
169    /// data the user did not put there, so it must be asked for explicitly.
170    #[must_use]
171    pub fn with_adopt(mut self, adopt: bool) -> Self {
172        self.adopt = adopt;
173        self
174    }
175
176    pub fn plan(&self, providers: &[&Provider], ws: &dyn Workspace) -> FsResult<Plan> {
177        let mut steps = Vec::new();
178        for provider in providers {
179            for &resource in ResourceKind::ALL {
180                let Some(capability) = provider.capability(resource) else {
181                    continue;
182                };
183                steps.push(self.step(provider, capability, ws)?);
184            }
185        }
186        steps.sort_by(|a, b| {
187            a.resource
188                .cmp(&b.resource)
189                .then_with(|| a.provider_id.cmp(&b.provider_id))
190        });
191        Ok(Plan { steps })
192    }
193
194    fn step(
195        &self,
196        provider: &Provider,
197        capability: &Capability,
198        ws: &dyn Workspace,
199    ) -> FsResult<Step> {
200        let resource = capability.resource;
201        let canonical = self.layout.canonical(resource).clone();
202        let outcome = self.decide(capability, &canonical, ws)?;
203        Ok(Step {
204            provider_id: provider.id.clone(),
205            provider_name: provider.name.clone(),
206            resource,
207            canonical: canonical.clone(),
208            target: capability.path.clone(),
209            outcome,
210            note: capability.note.clone(),
211            import_body: capability.import_body(&canonical),
212        })
213    }
214
215    fn decide(
216        &self,
217        capability: &Capability,
218        canonical: &RelPath,
219        ws: &dyn Workspace,
220    ) -> FsResult<Outcome> {
221        let node = capability.resource.node();
222        let canonical_entry = ws.probe(canonical)?;
223
224        // `native` is a claim about the tool, already validated against the
225        // canonical path at manifest load time. There is nothing to materialise.
226        if capability.strategy == Strategy::Native {
227            return Ok(match canonical_entry {
228                Some(_) => Outcome::Native,
229                None => Outcome::Skip(Skip::CanonicalMissing),
230            });
231        }
232
233        let Some(via) = self.resolve_via(capability, node) else {
234            return Ok(Outcome::Blocked(Blocked::Unsupported { node }));
235        };
236
237        let target_entry = ws.probe(&capability.path)?;
238
239        match (canonical_entry, target_entry) {
240            // The provider path holds the only copy. This is the common state of
241            // a repository that has been using one agent and is now adding
242            // agentlink, so it deserves a first-class path rather than an error.
243            (None, Some(target)) if target.is_concrete() => Ok(self.adoption(via)),
244
245            // Nothing to share yet: either the workspace is empty, or a dangling
246            // link is waiting for canonical content to appear.
247            (None, _) => Ok(Outcome::Skip(Skip::CanonicalMissing)),
248
249            (Some(_), None) => Ok(Outcome::Create { via }),
250
251            (Some(_), Some(target)) => self.reconcile(capability, canonical, via, &target, ws),
252        }
253    }
254
255    fn reconcile(
256        &self,
257        capability: &Capability,
258        canonical: &RelPath,
259        via: Via,
260        target: &Entry,
261        ws: &dyn Workspace,
262    ) -> FsResult<Outcome> {
263        if via == Via::Import {
264            return self.reconcile_import(capability, canonical, target, ws);
265        }
266
267        match &target.link {
268            // Already pointing where it should. We deliberately do not rewrite a
269            // junction into a symlink when privileges appear later: the link is
270            // correct, and churn in a repository is worse than a suboptimal but
271            // working mechanism.
272            Some(LinkTarget::Inside(actual)) if actual == canonical => {
273                Ok(Outcome::UpToDate { via })
274            }
275            Some(current) => Ok(if self.lock.owns(&capability.path) {
276                Outcome::Relink {
277                    via,
278                    current: current.clone(),
279                }
280            } else {
281                Outcome::Blocked(Blocked::ForeignLink {
282                    current: current.clone(),
283                })
284            }),
285            // Real content sits at the provider path while the canonical location
286            // also exists. Safe to adopt only if the canonical side is empty.
287            None => {
288                if Self::canonical_is_free(canonical, ws)? {
289                    Ok(self.adoption(via))
290                } else {
291                    Ok(Outcome::Blocked(Blocked::TargetOccupied))
292                }
293            }
294        }
295    }
296
297    fn reconcile_import(
298        &self,
299        capability: &Capability,
300        canonical: &RelPath,
301        target: &Entry,
302        ws: &dyn Workspace,
303    ) -> FsResult<Outcome> {
304        let expected = capability.import_body(canonical).unwrap_or_default();
305
306        // A link where we expect a stub is not ours to interpret.
307        if !target.is_concrete() {
308            return Ok(match &target.link {
309                Some(current) => Outcome::Blocked(Blocked::ForeignLink {
310                    current: current.clone(),
311                }),
312                None => Outcome::Blocked(Blocked::TargetOccupied),
313            });
314        }
315
316        let actual = ws.read(&capability.path)?;
317        if actual == expected {
318            return Ok(Outcome::UpToDate { via: Via::Import });
319        }
320        // The stub is one line of our own generated text. Rewriting it is safe
321        // only if we wrote it; otherwise the file is the user's.
322        Ok(if self.lock.owns(&capability.path) {
323            Outcome::Rewrite { via: Via::Import }
324        } else if Self::canonical_is_free(canonical, ws)? {
325            self.adoption(Via::Import)
326        } else {
327            Outcome::Blocked(Blocked::TargetOccupied)
328        })
329    }
330
331    /// Whether the canonical location can receive adopted content without
332    /// overwriting anything.
333    fn canonical_is_free(canonical: &RelPath, ws: &dyn Workspace) -> FsResult<bool> {
334        Ok(match ws.probe(canonical)? {
335            None => true,
336            Some(entry) if entry.node == NodeKind::Dir && entry.is_concrete() => {
337                ws.is_empty_dir(canonical)?
338            }
339            Some(_) => false,
340        })
341    }
342
343    fn adoption(&self, via: Via) -> Outcome {
344        if self.adopt {
345            Outcome::Adopt { via }
346        } else {
347            Outcome::Blocked(Blocked::NeedsAdopt)
348        }
349    }
350
351    /// Picks the mechanism: the provider's preferred strategy if the host allows
352    /// it, otherwise its declared fallback.
353    fn resolve_via(&self, capability: &Capability, node: NodeKind) -> Option<Via> {
354        match capability.strategy {
355            Strategy::Native => None,
356            Strategy::Import => Some(Via::Import),
357            Strategy::Link => match self.support.best_for(node) {
358                Some(via) => Some(via),
359                // No link primitive on this host. This is exactly the Windows
360                // file case: junctions cannot link a file and symlinks need
361                // privileges, so `CLAUDE.md` degrades to an `@AGENTS.md` stub.
362                None if capability.has_import_fallback() => Some(Via::Import),
363                None => None,
364            },
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::lock::LockEntry;
373    use crate::testing::FakeWorkspace;
374
375    fn rel(s: &str) -> RelPath {
376        RelPath::new(s).unwrap()
377    }
378
379    fn provider(toml_text: &str) -> Provider {
380        let layout = Layout::default();
381        crate::provider::parse("test.toml", toml_text, |kind| {
382            layout.canonical(kind).clone()
383        })
384        .expect("valid manifest")
385    }
386
387    fn claude() -> Provider {
388        provider(
389            r#"
390            schema = 1
391            id = "claude-code"
392            name = "Claude Code"
393
394            [[capability]]
395            resource = "instructions"
396            strategy = "link"
397            path = "CLAUDE.md"
398
399            [capability.fallback]
400            strategy = "import"
401            template = "@{canonical}\n"
402
403            [[capability]]
404            resource = "skills"
405            strategy = "link"
406            path = ".claude/skills"
407            "#,
408        )
409    }
410
411    fn antigravity() -> Provider {
412        provider(
413            r#"
414            schema = 1
415            id = "antigravity"
416            name = "Google Antigravity"
417
418            [[capability]]
419            resource = "instructions"
420            strategy = "native"
421            path = "AGENTS.md"
422
423            [[capability]]
424            resource = "skills"
425            strategy = "native"
426            path = ".agents/skills"
427            "#,
428        )
429    }
430
431    fn outcome_for(plan: &Plan, provider: &str, resource: ResourceKind) -> Outcome {
432        plan.steps
433            .iter()
434            .find(|step| step.provider_id == provider && step.resource == resource)
435            .unwrap_or_else(|| panic!("no step for {provider}/{resource}"))
436            .outcome
437            .clone()
438    }
439
440    fn plan_with(ws: &FakeWorkspace, lock: &Lock, providers: &[&Provider]) -> Plan {
441        let layout = Layout::default();
442        Planner::new(&layout, lock, ws.support())
443            .plan(providers, ws)
444            .expect("planning")
445    }
446
447    #[test]
448    fn a_tool_reading_the_canonical_path_costs_nothing() {
449        let ws = FakeWorkspace::unix();
450        ws.add_file("AGENTS.md", "# rules");
451        ws.add_dir(".agents/skills");
452
453        let plan = plan_with(&ws, &Lock::default(), &[&antigravity()]);
454
455        assert_eq!(
456            outcome_for(&plan, "antigravity", ResourceKind::Instructions),
457            Outcome::Native
458        );
459        assert_eq!(
460            outcome_for(&plan, "antigravity", ResourceKind::Skills),
461            Outcome::Native
462        );
463        // The entire point: a native provider triggers zero writes.
464        assert_eq!(plan.writes().count(), 0);
465        assert_eq!(plan.free(), 2);
466    }
467
468    #[test]
469    fn missing_canonical_content_is_skipped_not_invented() {
470        let ws = FakeWorkspace::unix();
471        let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
472
473        assert_eq!(
474            outcome_for(&plan, "claude-code", ResourceKind::Skills),
475            Outcome::Skip(Skip::CanonicalMissing)
476        );
477        assert_eq!(plan.writes().count(), 0);
478    }
479
480    #[test]
481    fn creates_symlinks_on_a_host_that_supports_them() {
482        let ws = FakeWorkspace::unix();
483        ws.add_file("AGENTS.md", "# rules");
484        ws.add_dir(".agents/skills");
485
486        let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
487
488        assert_eq!(
489            outcome_for(&plan, "claude-code", ResourceKind::Instructions),
490            Outcome::Create { via: Via::Symlink }
491        );
492        assert_eq!(
493            outcome_for(&plan, "claude-code", ResourceKind::Skills),
494            Outcome::Create { via: Via::Symlink }
495        );
496        assert_eq!(plan.linked(), 2);
497    }
498
499    #[test]
500    fn windows_without_privileges_junctions_directories_and_stubs_files() {
501        // The decisive cross-platform case. Skills are a directory, so they get a
502        // junction with no elevation. CLAUDE.md is a file with no available link
503        // primitive, so it degrades to Claude Code's own `@` import syntax.
504        let ws = FakeWorkspace::windows_unprivileged();
505        ws.add_file("AGENTS.md", "# rules");
506        ws.add_dir(".agents/skills");
507
508        let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
509
510        assert_eq!(
511            outcome_for(&plan, "claude-code", ResourceKind::Skills),
512            Outcome::Create { via: Via::Junction }
513        );
514        assert_eq!(
515            outcome_for(&plan, "claude-code", ResourceKind::Instructions),
516            Outcome::Create { via: Via::Import }
517        );
518    }
519
520    #[test]
521    fn a_provider_without_a_fallback_is_reported_unsupported_not_silently_dropped() {
522        let no_fallback = provider(
523            r#"
524            schema = 1
525            id = "strict"
526            name = "Strict"
527
528            [[capability]]
529            resource = "instructions"
530            strategy = "link"
531            path = "STRICT.md"
532            "#,
533        );
534        let ws = FakeWorkspace::windows_unprivileged();
535        ws.add_file("AGENTS.md", "# rules");
536
537        let plan = plan_with(&ws, &Lock::default(), &[&no_fallback]);
538
539        assert_eq!(
540            outcome_for(&plan, "strict", ResourceKind::Instructions),
541            Outcome::Blocked(Blocked::Unsupported {
542                node: NodeKind::File
543            })
544        );
545    }
546
547    #[test]
548    fn an_existing_correct_link_is_left_alone() {
549        let ws = FakeWorkspace::unix();
550        ws.add_file("AGENTS.md", "# rules");
551        ws.add_dir(".agents/skills");
552        ws.add_link(".claude/skills", NodeKind::Dir, ".agents/skills");
553        ws.add_link("CLAUDE.md", NodeKind::File, "AGENTS.md");
554
555        let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
556
557        assert_eq!(
558            outcome_for(&plan, "claude-code", ResourceKind::Skills),
559            Outcome::UpToDate { via: Via::Symlink }
560        );
561        assert!(plan.is_clean());
562        // Re-planning after apply must be a no-op: idempotence is what makes this
563        // safe to wire into a git hook.
564        assert_eq!(plan.writes().count(), 0);
565    }
566
567    #[test]
568    fn a_foreign_link_is_never_repointed_without_asking() {
569        let ws = FakeWorkspace::unix();
570        ws.add_file("AGENTS.md", "# rules");
571        ws.add_dir(".agents/skills");
572        ws.add_dir("somewhere/else");
573        ws.add_link(".claude/skills", NodeKind::Dir, "somewhere/else");
574
575        let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
576
577        assert_eq!(
578            outcome_for(&plan, "claude-code", ResourceKind::Skills),
579            Outcome::Blocked(Blocked::ForeignLink {
580                current: LinkTarget::Inside(rel("somewhere/else"))
581            })
582        );
583    }
584
585    #[test]
586    fn a_link_we_created_is_repointed_freely() {
587        let ws = FakeWorkspace::unix();
588        ws.add_file("AGENTS.md", "# rules");
589        ws.add_dir(".agents/skills");
590        ws.add_dir("old/skills");
591        ws.add_link(".claude/skills", NodeKind::Dir, "old/skills");
592
593        let mut lock = Lock::default();
594        lock.record(LockEntry {
595            provider: "claude-code".into(),
596            resource: ResourceKind::Skills,
597            target: rel(".claude/skills"),
598            canonical: rel("old/skills"),
599            via: Via::Symlink,
600        });
601
602        let plan = plan_with(&ws, &lock, &[&claude()]);
603
604        assert_eq!(
605            outcome_for(&plan, "claude-code", ResourceKind::Skills),
606            Outcome::Relink {
607                via: Via::Symlink,
608                current: LinkTarget::Inside(rel("old/skills"))
609            }
610        );
611    }
612
613    #[test]
614    fn existing_provider_content_asks_before_moving_anything() {
615        // A repo that has been using Claude Code and has no .agents/ yet.
616        let ws = FakeWorkspace::unix();
617        ws.add_dir(".claude/skills");
618        ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
619
620        let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
621
622        // Default posture never relocates user data.
623        assert_eq!(
624            outcome_for(&plan, "claude-code", ResourceKind::Skills),
625            Outcome::Blocked(Blocked::NeedsAdopt)
626        );
627    }
628
629    #[test]
630    fn adoption_moves_content_into_the_canonical_location_when_asked() {
631        let ws = FakeWorkspace::unix();
632        ws.add_dir(".claude/skills");
633        ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
634
635        let layout = Layout::default();
636        let lock = Lock::default();
637        let plan = Planner::new(&layout, &lock, ws.support())
638            .with_adopt(true)
639            .plan(&[&claude()], &ws)
640            .expect("planning");
641
642        assert_eq!(
643            outcome_for(&plan, "claude-code", ResourceKind::Skills),
644            Outcome::Adopt { via: Via::Symlink }
645        );
646    }
647
648    #[test]
649    fn content_on_both_sides_is_a_merge_only_a_human_can_do() {
650        let ws = FakeWorkspace::unix();
651        ws.add_dir(".agents/skills");
652        ws.add_file(".agents/skills/deploy/SKILL.md", "---\nname: deploy\n---\n");
653        ws.add_dir(".claude/skills");
654        ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
655
656        let layout = Layout::default();
657        let lock = Lock::default();
658        // Even with --adopt, we refuse: adopting would silently discard one side.
659        let plan = Planner::new(&layout, &lock, ws.support())
660            .with_adopt(true)
661            .plan(&[&claude()], &ws)
662            .expect("planning");
663
664        assert_eq!(
665            outcome_for(&plan, "claude-code", ResourceKind::Skills),
666            Outcome::Blocked(Blocked::TargetOccupied)
667        );
668    }
669
670    #[test]
671    fn an_empty_canonical_directory_still_accepts_adoption() {
672        let ws = FakeWorkspace::unix();
673        ws.add_dir(".agents/skills");
674        ws.add_dir(".claude/skills");
675        ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
676
677        let layout = Layout::default();
678        let lock = Lock::default();
679        let plan = Planner::new(&layout, &lock, ws.support())
680            .with_adopt(true)
681            .plan(&[&claude()], &ws)
682            .expect("planning");
683
684        assert_eq!(
685            outcome_for(&plan, "claude-code", ResourceKind::Skills),
686            Outcome::Adopt { via: Via::Symlink }
687        );
688    }
689
690    #[test]
691    fn a_correct_import_stub_is_up_to_date() {
692        let ws = FakeWorkspace::windows_unprivileged();
693        ws.add_file("AGENTS.md", "# rules");
694        ws.add_file("CLAUDE.md", "@AGENTS.md\n");
695
696        let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
697
698        assert_eq!(
699            outcome_for(&plan, "claude-code", ResourceKind::Instructions),
700            Outcome::UpToDate { via: Via::Import }
701        );
702    }
703
704    #[test]
705    fn a_stale_stub_we_own_is_rewritten() {
706        let ws = FakeWorkspace::windows_unprivileged();
707        ws.add_file("AGENTS.md", "# rules");
708        ws.add_file("CLAUDE.md", "@OLD.md\n");
709
710        let mut lock = Lock::default();
711        lock.record(LockEntry {
712            provider: "claude-code".into(),
713            resource: ResourceKind::Instructions,
714            target: rel("CLAUDE.md"),
715            canonical: rel("AGENTS.md"),
716            via: Via::Import,
717        });
718
719        let plan = plan_with(&ws, &lock, &[&claude()]);
720
721        assert_eq!(
722            outcome_for(&plan, "claude-code", ResourceKind::Instructions),
723            Outcome::Rewrite { via: Via::Import }
724        );
725    }
726
727    #[test]
728    fn a_handwritten_claude_md_is_never_silently_replaced_by_a_stub() {
729        // The single most important safety case: a user with real content in
730        // CLAUDE.md and real content in AGENTS.md must not lose either.
731        let ws = FakeWorkspace::windows_unprivileged();
732        ws.add_file("AGENTS.md", "# shared rules");
733        ws.add_file("CLAUDE.md", "# my carefully written Claude instructions");
734
735        let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
736
737        assert_eq!(
738            outcome_for(&plan, "claude-code", ResourceKind::Instructions),
739            Outcome::Blocked(Blocked::TargetOccupied)
740        );
741    }
742
743    #[test]
744    fn steps_are_ordered_deterministically() {
745        let ws = FakeWorkspace::unix();
746        ws.add_file("AGENTS.md", "# rules");
747        ws.add_dir(".agents/skills");
748
749        let plan = plan_with(&ws, &Lock::default(), &[&claude(), &antigravity()]);
750
751        let order: Vec<_> = plan
752            .steps
753            .iter()
754            .map(|step| (step.resource, step.provider_id.as_str()))
755            .collect();
756        assert_eq!(
757            order,
758            [
759                (ResourceKind::Instructions, "antigravity"),
760                (ResourceKind::Instructions, "claude-code"),
761                (ResourceKind::Skills, "antigravity"),
762                (ResourceKind::Skills, "claude-code"),
763            ]
764        );
765    }
766}