mind-cli 0.44.0

A manager for agent tooling (skills, agents, rules, tools) that melds arbitrary git repos and links items into your agent directories.
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
//! Integration tests that turn prose claims in `docs/` and `CHANGELOG.md` into
//! enforced behavior.
//!
//! The documentation for a change is written by a different pass than the
//! implementation, so a doc sentence can describe behavior that never landed,
//! or overstate behavior that landed with a caveat. Every claim that is
//! observable from the CLI belongs in a test; this file holds the ones with no
//! other home.
//!
//! Hermetic: local git repos in temp dirs, `MIND_HOME`/`CLAUDE_HOME` pointed at
//! temp dirs, and an executable stub earlier on `PATH` wherever a regression
//! could otherwise reach the network.
//!
//! Claims covered:
//!   - docs/src/validate.md: `review` reads a target
//!     naming an existing directory as that local path (CLI-214) -- including
//!     the two-segment relative form the docs single out, and the registry
//!     precedence the docs' "always" wording omits.
//!   - docs/src/troubleshooting.md: the dead-local-source recovery recipe
//!     (`mind recall --sources`, then `mind unmeld <name>`) actually works
//!     end to end after the directory is gone (CLI-213).
//!   - docs/src/lobes.md: `config lobes add` backfills already-installed
//!     items into a new lobe only for the kinds the lobe admits (HARN-17), not
//!     every installed item.
//!   - docs/src/lobes.md: `config lobes add` creates its target
//!     directory immediately only on the managed (non-`--snapshot`) path
//!     (HARN-15).
//!   - docs/src/lobes.md: the hand-written `kinds = ["workflow"]` lobe
//!     the workflow section tells a reader to write admits a workflow and
//!     nothing else (WF-12).
//!   - docs/src/commands.md (and `forget --help`): the source-qualified
//!     `forget` examples for a local source remove one item, every skill, and
//!     every item (CLI-40, CLI-41).

use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU32, Ordering};

static COUNTER: AtomicU32 = AtomicU32::new(0);

struct Sandbox {
    base: PathBuf,
    mind_home: PathBuf,
    claude_home: PathBuf,
}

struct Run {
    stdout: String,
    stderr: String,
    success: bool,
}

impl Sandbox {
    fn new(tag: &str) -> Sandbox {
        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
        let base = std::env::temp_dir().join(format!("mind-doc-{tag}-{}-{n}", std::process::id()));
        let _ = std::fs::remove_dir_all(&base);
        std::fs::create_dir_all(&base).unwrap();
        Sandbox {
            mind_home: base.join("mind"),
            claude_home: base.join("claude"),
            base,
        }
    }

    /// Run `mind` from `cwd`, optionally with `path_prefix` prepended to `PATH`
    /// (used to shadow `git` so a regression cannot reach a real remote).
    fn mind_in(&self, cwd: &Path, path_prefix: Option<&Path>, args: &[&str]) -> Run {
        let mut cmd = Command::new(env!("CARGO_BIN_EXE_mind"));
        cmd.args(args)
            .current_dir(cwd)
            .env("MIND_HOME", &self.mind_home)
            .env("CLAUDE_HOME", &self.claude_home)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .stdin(Stdio::null());
        if let Some(prefix) = path_prefix {
            let orig = std::env::var("PATH").unwrap_or_default();
            cmd.env("PATH", format!("{}:{orig}", prefix.display()));
        }
        let out = cmd.output().expect("run mind");
        Run {
            stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
            success: out.status.success(),
        }
    }

    fn mind(&self, args: &[&str]) -> Run {
        self.mind_in(&self.base, None, args)
    }
}

impl Drop for Sandbox {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.base);
    }
}

fn write(path: &Path, contents: &str) {
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    std::fs::write(path, contents).unwrap();
}

fn write_exec(path: &Path, contents: &str) {
    write(path, contents);
    use std::os::unix::fs::PermissionsExt;
    let mut perms = std::fs::metadata(path).unwrap().permissions();
    perms.set_mode(0o755);
    std::fs::set_permissions(path, perms).unwrap();
}

fn git(dir: &Path, args: &[&str]) {
    let status = Command::new("git")
        .args(args)
        .current_dir(dir)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .expect("run git");
    assert!(status.success(), "git {args:?} failed in {dir:?}");
}

fn init_repo(dir: &Path) {
    write(&dir.join("README.md"), "# fixture\n");
    git(dir, &["-c", "init.defaultBranch=main", "init", "-q"]);
    git(dir, &["config", "user.email", "t@t"]);
    git(dir, &["config", "user.name", "t"]);
    git(dir, &["add", "-A"]);
    git(dir, &["commit", "-qm", "initial"]);
}

/// A `git` stub that records every invocation and always fails, so a
/// regression that took the remote-clone branch is a recorded local failure
/// rather than a live network call.
fn stub_git(bin_dir: &Path, log: &Path) -> PathBuf {
    let script = format!(
        "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {log:?}\nexit 1\n",
        log = log
    );
    write_exec(&bin_dir.join("git"), &script);
    bin_dir.to_path_buf()
}

// ---------------------------------------------------------------------------
// docs/src/validate.md --
// "A target naming an existing directory is always read as that local path,
// even when it also looks like a valid `owner/repo` spec".
// ---------------------------------------------------------------------------

/// The motivating case CLI-214 names: a two-segment RELATIVE path, which
/// `parse_spec` would otherwise read as `owner/repo` and shallow-clone from
/// github.com. `review` must read it as the local directory, print the note
/// naming the reading it took, and make no clone attempt at all.
///
/// The existing unit test for this (`src/review.rs`
/// `resolve_target_prefers_an_existing_absolute_directory`) passes an
/// ABSOLUTE path, which `parse_spec` already resolves locally; it therefore
/// never exercises the branch that fixes the reported bug.
#[test]
fn review_reads_a_two_segment_relative_path_as_a_local_dir_without_cloning() {
    // spec: CLI-214
    let sb = Sandbox::new("rel-review");
    let work = sb.base.join("work");
    let bin = sb.base.join("bin");
    std::fs::create_dir_all(&bin).unwrap();
    let git_log = sb.base.join("git.log");
    let path_prefix = stub_git(&bin, &git_log);

    // `<work>/skills/greet` is a source directory in its own right, carrying a
    // malformed mind.toml. That is a HARD review finding, so the exit status
    // itself proves these bytes -- and not a clone of github.com/skills/greet
    // -- are what got read.
    write(
        &work.join("skills/greet/mind.toml"),
        "this is not = valid = toml\n",
    );

    let r = sb.mind_in(&work, Some(&path_prefix), &["review", "skills/greet"]);
    assert!(
        !git_log.exists(),
        "review must not invoke git at all for a local directory target; \
         recorded invocations: {:?}",
        std::fs::read_to_string(&git_log).unwrap_or_default()
    );
    assert!(
        !r.success,
        "the local directory's malformed mind.toml is a hard finding, so this \
         must exit non-zero -- proving the local bytes were read: {}\n{}",
        r.stdout, r.stderr
    );
    let combined = format!("{}{}", r.stdout, r.stderr);
    assert!(
        combined.contains("mind.toml") || combined.to_lowercase().contains("toml"),
        "the failure must be the local mind.toml parse, not a clone failure: {combined}"
    );
    // CLI-214: the note names the reading taken and the escape to force the
    // remote one.
    assert!(
        r.stderr.contains("skills/greet") && r.stderr.contains("mind review 'github:skills/greet'"),
        "the shadow note must name the target and the remote-forcing escape: {}",
        r.stderr
    );
}

/// The clean-directory counterpart: a valid local source at a two-segment
/// relative path reviews cleanly and still makes no clone attempt, so the
/// CLI-214 branch is not merely turning a clone failure into a different
/// failure.
#[test]
fn review_relative_path_to_a_clean_local_source_exits_zero_without_cloning() {
    // spec: CLI-214
    let sb = Sandbox::new("rel-clean");
    let work = sb.base.join("work");
    let bin = sb.base.join("bin");
    std::fs::create_dir_all(&bin).unwrap();
    let git_log = sb.base.join("git.log");
    let path_prefix = stub_git(&bin, &git_log);

    write(
        &work.join("skills/greet/skills/hello/SKILL.md"),
        "---\ndescription: say hello\n---\n# hello\n",
    );

    let r = sb.mind_in(&work, Some(&path_prefix), &["review", "skills/greet"]);
    assert!(
        r.success,
        "a clean local source at a two-segment relative path must review \
         cleanly: {}\n{}",
        r.stdout, r.stderr
    );
    assert!(
        !git_log.exists(),
        "no clone may be attempted: {:?}",
        std::fs::read_to_string(&git_log).unwrap_or_default()
    );
}

/// DOCUMENTATION INACCURACY, pinned as behavior: the docs say a target naming
/// an existing directory is read as that local path "always". It is not. Per
/// CLI-214's own wording, the check sits BETWEEN the registry-selector match
/// and repo-spec parsing, so a registered source whose identity the target
/// matches (by exact or suffix match) wins over an identically named directory
/// in the cwd. `src/review.rs:165` states the real precedence:
/// "exact/suffix registry match > existing local directory > remote spec".
///
/// This test pins that precedence so the behavior cannot drift, and stands as
/// the counterexample to the word "always" in docs/src/validate.md.
#[test]
fn review_registry_selector_wins_over_an_identically_named_local_directory() {
    // spec: CLI-214
    let sb = Sandbox::new("registry-wins");

    // The melded source is named `greet` and is CLEAN (reviews exit 0).
    let melded = sb.base.join("greet");
    write(
        &melded.join("skills/hello/SKILL.md"),
        "---\ndescription: say hello\n---\n# hello\n",
    );
    init_repo(&melded);
    let r = sb.mind(&["meld", melded.to_string_lossy().as_ref(), "--register-only"]);
    assert!(r.success, "meld: {}\n{}", r.stdout, r.stderr);

    // A DIFFERENT directory, also named `greet`, in the working directory,
    // carrying a malformed mind.toml (a hard finding: non-zero exit).
    let work = sb.base.join("work");
    write(
        &work.join("greet/mind.toml"),
        "this is not = valid = toml\n",
    );

    let r = sb.mind_in(&work, None, &["review", "greet"]);
    assert!(
        r.success,
        "the registry match wins, so the CLEAN melded source is what gets \
         reviewed -- if the local ./greet had won, its malformed mind.toml \
         would be a hard finding and this would exit non-zero: {}\n{}",
        r.stdout, r.stderr
    );
    assert!(
        r.stdout.contains("no issues found"),
        "the clean melded source is what was reviewed: {}",
        r.stdout
    );
}

/// The complement: with NO registered source to match, the same string does
/// resolve to the local directory, so the registry precedence above is the
/// only thing that displaces it.
#[test]
fn review_local_directory_wins_when_no_source_is_registered() {
    // spec: CLI-214
    let sb = Sandbox::new("local-wins");
    let work = sb.base.join("work");
    write(
        &work.join("greet/mind.toml"),
        "this is not = valid = toml\n",
    );

    let r = sb.mind_in(&work, None, &["review", "greet"]);
    assert!(
        !r.success,
        "with nothing registered, ./greet is what is reviewed, and its \
         malformed mind.toml is a hard finding: {}\n{}",
        r.stdout, r.stderr
    );
}

// ---------------------------------------------------------------------------
// docs/src/troubleshooting.md -- the dead-local-source recovery recipe.
// ---------------------------------------------------------------------------

/// The documented recovery steps, executed verbatim: after the melded
/// directory is deleted, `mind recall --sources` must still run and must print
/// a name, and `mind unmeld <that name>` must actually drop the source. The
/// existing CLI-213 coverage stops at "recall degrades"; the second half of
/// the recipe -- the step that makes the situation recoverable -- was never
/// exercised.
#[test]
fn dead_local_source_recovery_recipe_from_the_docs_works_end_to_end() {
    // spec: CLI-213
    let sb = Sandbox::new("dead-source");
    let source = sb.base.join("greet");
    write(
        &source.join("skills/hello/SKILL.md"),
        "---\ndescription: say hello\n---\n# hello\n",
    );
    init_repo(&source);
    let r = sb.mind(&["meld", source.to_string_lossy().as_ref(), "--register-only"]);
    assert!(r.success, "meld: {}\n{}", r.stdout, r.stderr);

    let identity = format!(
        "local/{}/greet",
        sb.base.file_name().unwrap().to_string_lossy()
    );

    // The directory goes away.
    std::fs::remove_dir_all(&source).unwrap();

    // Step 1: `mind recall --sources` to find the source's name.
    let r = sb.mind(&["recall", "--sources"]);
    assert!(
        r.success,
        "recall --sources must still run with the directory gone: {}\n{}",
        r.stdout, r.stderr
    );
    assert!(
        r.stdout.contains(&identity),
        "the recipe says to read the source's name here, so the full identity \
         `unmeld` accepts must be printed: {}",
        r.stdout
    );

    // Step 2: `mind unmeld <name>` to drop it.
    // `--yes`: this run has no terminal to confirm on (CLI-259).
    let r = sb.mind(&["unmeld", &identity, "--yes"]);
    assert!(
        r.success,
        "unmeld must drop a source whose linked working tree is gone -- this \
         is the documented remedy, and it is the only way out: {}\n{}",
        r.stdout, r.stderr
    );

    // The source is gone, and the warning with it.
    let r = sb.mind(&["recall", "--sources"]);
    assert!(r.success, "recall after unmeld: {}\n{}", r.stdout, r.stderr);
    assert!(
        !r.stdout.contains(&identity),
        "the dropped source must no longer be listed: {}",
        r.stdout
    );
    let r = sb.mind(&["recall"]);
    assert!(
        r.success && !r.stderr.contains("is gone"),
        "the vanished-source warning must be gone once it is unmelded: {}\n{}",
        r.stdout,
        r.stderr
    );
}

/// The `forget` examples in `mind forget --help` and docs/src/commands.md that
/// qualify an item by a local source's identity (`local/<parent>/<dir>#...`):
/// one item, every skill, then every item, each removing only what it names.
#[test]
fn forget_examples_for_a_local_source_remove_what_they_name() {
    // spec: CLI-40 CLI-41
    let sb = Sandbox::new("forget-local");
    let source = sb.base.join("agents");
    write(
        &source.join("skills/alpha/SKILL.md"),
        "---\ndescription: alpha\n---\n# alpha\n",
    );
    write(
        &source.join("skills/beta/SKILL.md"),
        "---\ndescription: beta\n---\n# beta\n",
    );
    write(
        &source.join("agents/gamma.md"),
        "---\nname: gamma\ndescription: gamma\n---\n# gamma\n",
    );
    init_repo(&source);
    let r = sb.mind(&["meld", source.to_string_lossy().as_ref(), "--yes"]);
    assert!(r.success, "meld: {}\n{}", r.stdout, r.stderr);

    let identity = format!(
        "local/{}/agents",
        sb.base.file_name().unwrap().to_string_lossy()
    );
    let alpha = sb.claude_home.join("skills/alpha");
    let beta = sb.claude_home.join("skills/beta");
    let gamma = sb.claude_home.join("agents/gamma.md");
    for link in [&alpha, &beta, &gamma] {
        assert!(link.exists(), "{} must be installed", link.display());
    }

    // One item.
    let r = sb.mind(&["forget", &format!("{identity}#alpha")]);
    assert!(r.success, "forget one: {}\n{}", r.stdout, r.stderr);
    assert!(!alpha.exists() && beta.exists() && gamma.exists());

    // Every skill.
    let r = sb.mind(&["forget", &format!("{identity}#skill:*"), "--yes"]);
    assert!(r.success, "forget skills: {}\n{}", r.stdout, r.stderr);
    assert!(!beta.exists() && gamma.exists());

    // Every item.
    let r = sb.mind(&["forget", &format!("{identity}#*"), "--yes"]);
    assert!(r.success, "forget all: {}\n{}", r.stdout, r.stderr);
    assert!(!gamma.exists());

    // The source itself stays melded; only `unmeld` drops it.
    let r = sb.mind(&["recall", "--sources"]);
    assert!(r.stdout.contains(&identity), "{}", r.stdout);
}

/// The other half of the recipe's premise: `mind unmeld` of the vanished
/// source must not be blocked by the same scan failure that degrades the
/// listing verbs. If `unmeld` itself hard-failed on the gone directory, the
/// documented recovery would be a dead end.
#[test]
fn unmeld_of_a_vanished_source_does_not_require_the_directory_to_exist() {
    // spec: CLI-213
    let sb = Sandbox::new("unmeld-gone");
    let source = sb.base.join("ghost");
    write(
        &source.join("skills/hello/SKILL.md"),
        "---\ndescription: say hello\n---\n# hello\n",
    );
    init_repo(&source);
    assert!(
        sb.mind(&["meld", source.to_string_lossy().as_ref(), "--register-only"])
            .success
    );
    std::fs::remove_dir_all(&source).unwrap();

    // A bare suffix selector (what a user would actually type) must work too,
    // not only the full identity.
    let r = sb.mind(&["unmeld", "ghost", "--yes"]);
    assert!(
        r.success,
        "unmeld by the short selector must work on a vanished source: {}\n{}",
        r.stdout, r.stderr
    );
}

// ---------------------------------------------------------------------------
// docs/src/lobes.md -- "Already-installed items link into the
// new lobe as part of the same command" omits that the lobe's `kinds` filter
// still applies (HARN-17): a skill-only lobe backfills no rule.
// ---------------------------------------------------------------------------

/// A skill and a rule are installed BEFORE the lobe exists. Adding a
/// `devin`-preset (skill-only) lobe afterward must backfill the skill but
/// not the rule, proving the doc's unqualified "already-installed items link
/// in" statement is only true for the kinds the lobe admits.
#[test]
fn config_lobes_add_backfills_only_the_kinds_the_lobe_admits() {
    // spec: HARN-17
    let sb = Sandbox::new("kinds-backfill");
    let source = sb.base.join("agents");
    write(
        &source.join("skills/hello/SKILL.md"),
        "---\ndescription: say hello\n---\n# hello\n",
    );
    write(
        &source.join("rules/style-rule.md"),
        "---\ndescription: ASCII only\n---\n# style\n",
    );
    init_repo(&source);

    assert!(
        sb.mind(&["meld", source.to_string_lossy().as_ref(), "--register-only"])
            .success
    );
    assert!(sb.mind(&["learn", "hello"]).success, "learn skill");
    assert!(sb.mind(&["learn", "style-rule"]).success, "learn rule");

    let project = sb.base.join("project");
    std::fs::create_dir_all(&project).unwrap();
    let r = sb.mind(&[
        "config",
        "lobes",
        "add",
        project.to_string_lossy().as_ref(),
        "--preset",
        "devin",
    ]);
    assert!(r.success, "config lobes add: {}\n{}", r.stdout, r.stderr);

    let devin = project.join(".devin");
    assert!(
        std::fs::symlink_metadata(devin.join("skills/hello")).is_ok(),
        "the skill is a kind the devin lobe admits, so it must be backfilled: {}",
        r.stdout
    );
    assert!(
        std::fs::symlink_metadata(devin.join("rules/style-rule.md")).is_err(),
        "the devin preset is skill-only, so the already-installed rule must \
         NOT be backfilled into it: {}",
        r.stdout
    );
}

// ---------------------------------------------------------------------------
// docs/src/lobes.md -- "Adding a lobe creates its target directory
// immediately" omits that this is true only on the managed (non-`--snapshot`)
// path (HARN-15).
// ---------------------------------------------------------------------------

/// A plain (managed) `config lobes add` creates the target directory
/// immediately; the same command with `--snapshot` on an empty install set
/// creates nothing, so the doc's unqualified claim only holds for the managed
/// path.
#[test]
fn config_lobes_add_creates_target_dir_only_on_the_managed_path() {
    // spec: HARN-15
    let sb = Sandbox::new("mkdir-managed");

    let managed = sb.base.join("managed-target");
    assert!(!managed.exists(), "precondition: target must not pre-exist");
    let r = sb.mind(&["config", "lobes", "add", managed.to_string_lossy().as_ref()]);
    assert!(r.success, "config lobes add: {}\n{}", r.stdout, r.stderr);
    assert!(
        std::fs::symlink_metadata(&managed).is_ok(),
        "a managed lobe add must create its target directory immediately: {}",
        r.stdout
    );

    let snap = sb.base.join("snapshot-target");
    assert!(!snap.exists(), "precondition: target must not pre-exist");
    let r = sb.mind(&[
        "config",
        "lobes",
        "add",
        snap.to_string_lossy().as_ref(),
        "--snapshot",
    ]);
    assert!(r.success, "snapshot add: {}\n{}", r.stdout, r.stderr);
    assert!(
        std::fs::symlink_metadata(&snap).is_err(),
        "--snapshot must NOT create the target directory (HARN-15 is confined \
         to the managed path): {}",
        r.stdout
    );
}

// ---------------------------------------------------------------------------
// docs/src/lobes.md -- "a workflow links into ... any lobe whose
// `kinds` names `workflow`", shown as the hand-written
// `{ path = "./.claude", kinds = ["workflow"] }` recipe. The no-filter and
// skills-only halves of WF-12 are covered elsewhere (cli_lobes.rs,
// cli_workflows.rs); the recipe the docs actually tell a reader to WRITE is the
// positive filter, which nothing exercised.
// ---------------------------------------------------------------------------

/// A hand-written `kinds = ["workflow"]` lobe receives a workflow and nothing
/// else. Both halves matter: if the filter admitted every kind the recipe would
/// be misleading in the other direction, and a reader copying it would fan a
/// skill out into a directory they meant to keep workflow-only.
#[test]
fn a_workflow_kinds_lobe_receives_a_workflow_and_no_other_kind() {
    // spec: WF-12 HARN-17
    let sb = Sandbox::new("workflow-kinds-lobe");
    let source = sb.base.join("agents");
    write(
        &source.join("workflows/ship.js"),
        "export const meta = { name: 'ship', description: 'Ship it' };\n",
    );
    write(
        &source.join("skills/hello/SKILL.md"),
        "---\ndescription: say hello\n---\n# hello\n",
    );
    init_repo(&source);

    // The docs' second snippet, verbatim in shape: the default Claude lobe plus
    // a second lobe restricted to the workflow kind.
    let project = sb.base.join("project/.claude");
    std::fs::create_dir_all(&project).unwrap();
    write(
        &sb.mind_home.join("config.toml"),
        &format!(
            "[[lobes]]\npath = \"{}\"\n\n[[lobes]]\npath = \"{}\"\nkinds = [\"workflow\"]\n",
            sb.claude_home.display(),
            project.display(),
        ),
    );

    assert!(
        sb.mind(&["meld", source.to_string_lossy().as_ref(), "--register-only"])
            .success
    );
    let r = sb.mind(&["learn", "workflow:ship"]);
    assert!(r.success, "learn workflow: {}\n{}", r.stdout, r.stderr);
    let r = sb.mind(&["learn", "skill:hello"]);
    assert!(r.success, "learn skill: {}\n{}", r.stdout, r.stderr);

    assert!(
        std::fs::symlink_metadata(project.join("workflows/ship.js")).is_ok(),
        "a lobe whose kinds names `workflow` must receive the workflow link: {}",
        r.stdout
    );
    assert!(
        std::fs::symlink_metadata(project.join("skills/hello")).is_err(),
        "the same filter must admit NOTHING else, or the documented recipe \
         quietly fans every kind into the project: {}",
        r.stdout
    );
    // Control: the unfiltered default lobe got both, so the absence above is
    // the filter and not a failed install.
    assert!(
        std::fs::symlink_metadata(sb.claude_home.join("workflows/ship.js")).is_ok()
            && std::fs::symlink_metadata(sb.claude_home.join("skills/hello")).is_ok(),
        "the unfiltered lobe must receive both items"
    );
}