mind-cli 0.48.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
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
# Storage

The on-disk layout and the two persisted JSON files.

## Layout

```
~/.mind/
  sources.json                  source registry
  manifest.json                 installed-item manifest
  sources/<host>/<owner>/<repo> clone of each melded repo
  store/<kind>/<name>/          installed copy of each item (name is effective)
  .tmp/staging|backup/...        scratch for transactional installs
  .lock                         global advisory lock (STO-40)

<agent home>/                   (one or more; default ~/.claude)
  skills/<name>      -> store/skill/<name>
  agents/<name>.md   -> store/agent/<name>
  rules/<name>.md    -> store/rule/<name>
  commands/<name>.md -> store/command/<name>
```

- `STO-1` The mind root is `$MIND_HOME` if set, else `~/.mind`. The claude root is
  `$MIND_DEFAULT_LOBE` if set, else `$CLAUDE_HOME` if set, else `~/.claude`
  (`$MIND_DEFAULT_LOBE` takes precedence over `$CLAUDE_HOME`, CLI-170). All
  overrides are honored everywhere.
- `STO-2` The default link target for an item, relative to an agent home, is
  `skills/<name>` (skill), `agents/<name>.md` (agent), `rules/<name>.md` (rule),
  or `commands/<name>.md` (command, commands.md CMD-5), where `<name>` is the
  effective name. A tool has no default link
  target: it is store-only (tooling.md TOOL-3). A `mind.toml` item may override
  the link target (applied in every home), which is how a tool opts into a link.
  Note: the `skills/<name>` and `agents/<name>.md` layouts are also the
  cross-tool conventions for Gemini CLI, Codex CLI, and Antigravity, so mind
  links into those homes with no content transform (HARN-3; see
  harness-lobes.md). A lobe may carry a `kinds` filter limiting which item kinds
  are linked into it (HARN-1); rules are not linked into non-Claude preset lobes
  (HARN-3).
- `STO-3` Store and link paths use the effective name, so namespaced items do not
  collide with same-named items from other sources.
- `STO-14` The agent homes ("lobes") items are linked into are, in order:
  `$MIND_AGENT_HOMES` (a `:`-separated path list), else `lobes` in
  `~/.mind/config.toml`, else `[claude root]`. A leading `~` is expanded. An
  unknown key in `config.toml` is an error (`Toml`).
  Note: each lobe entry may carry a `kinds` filter (HARN-1). Non-Claude lobe
  presets (gemini, codex, universal, devin, devin-cli, windsurf) are added via
  `config lobes add --preset <name>` (HARN-4) or the auto-detect-and-prompt path
  `config lobes detect` (HARN-5). See harness-lobes.md for the preset path table
  and per-harness `kinds` defaults.
  Note: a lobe may be a global agent home directory (e.g. `~/.gemini/config`) or
  a project-scoped subdirectory (e.g. `<project>/.devin`); both are unified as
  "any install-target directory". See STO-56 for the reachability gate that
  governs project-scoped lobes.
- `STO-15` When `~/.mind/config.toml` does not exist, it is created with the
  default lobe (the `$MIND_DEFAULT_LOBE` override if set, else the `$CLAUDE_HOME`
  override if set, else `~/.claude`; CLI-170) on first use (any command that sets
  up the layout, or any `config` command).
- `STO-16` An agent home given as a relative path (after `~` expansion) is
  resolved to an absolute path against the current directory before items are
  linked, so the link paths recorded in the manifest do not depend on the
  working directory at a later command (e.g. an `uninstall` run elsewhere).

## Source registry (sources.json)

- `STO-10` Each source records: `name`, `url`, `host`, `owner`, `repo`, `commit`
  (last synced, or absent), `description` (from `mind.toml`, optional), `alias`
  (the effective display prefix - `--as`, a curated `as`/`namespace`, a
  marketplace entry name, an accepted `[source].prefix`, or a collision prompt;
  optional), and `as_alias` (the identity alias, STO-58: the subset of `alias`
  known before the clone; optional, absent for a bare meld or a display-only
  prefix).
- `STO-11` A source's clone lives at `sources/<host>/<owner>/<repo>`. For local or
  `file://` specs, host is `local` and owner is the path's parent directory.
- `STO-12` A missing registry file is treated as an empty registry.
- `STO-68` `Registry::load` revalidates every entry it reads, not just its
  schema version (STO-50): each entry's `host`/`owner`/`repo` are re-checked
  with the same per-part rules `make_source` applies at parse time (CLI-204),
  its `as_alias` (when present) is re-checked with `validate_prefix` (NS-25,
  NS-28, NS-29), and its pin value (when the pin carries one - `follow-branch`,
  `tag`, or `ref`; `default-branch` has none) is re-checked with
  `git::validate_ref_value` (DSC-66). This closes the gap a schema-version
  check alone leaves open: an entry can be structurally valid JSON at the
  current schema version while carrying a value an older, looser `parse_spec`
  accepted and a newer one would refuse (e.g. 0.21.0's parser accepted
  `repo: ".."`). A failing entry is DROPPED from the in-memory registry, not
  treated as a load error: hard-erroring would brick every `mind` verb for a
  user who happens to be carrying such a stale entry, whereas dropping it
  degrades gracefully to "that one source is gone, everything else still
  works." Each drop prints one warning to stderr naming `sources.json`, the
  dropped entry's `name`, and which part failed. The drop is not written back
  immediately; it becomes permanent the next time `Registry::save` runs (e.g.
  as part of the same command's normal write-back), same as any other
  in-memory registry mutation.
- `STO-13` A source's identity is its `name`, `host/owner/repo` (equal to its
  clone path under `sources/`, absent an alias or item-link suffix; see STO-58).
  Repos that share a basename, or even an `owner/repo` across different hosts,
  are distinct sources and coexist in one registry.
- `STO-58` The *identity alias* is part of a source's identity. It is the
  namespace declared BEFORE the clone: the consumer `--as <alias>` (STO-10), a
  curated `[discover].sources` entry's `as`/`namespace` (DSC-78), or a marketplace
  entry name (MKT-8). A source with an identity alias carries a trailing
  `@<alias>` segment on its `name` (`host/owner/repo@<alias>`), composing with an
  item-link `#<path>` suffix (LNK-4) as `host/owner/repo#<path>@<alias>` (`@<alias>`
  is always last). So a bare meld of a repo, and one or more identity-aliased melds
  of the same repo under distinct aliases, are distinct registry entries that
  coexist with independent pins, commits, clones, and lifecycles - the mechanism a
  consumer uses to track several branches or versions of one source side by side,
  each installing its items under its own prefix. Two melds that resolve to the
  same `host/owner/repo@<alias>` identity are the same instance (a re-meld, CLI-12).
  A prefix decided AFTER the clone - an accepted `[source].prefix` (CLI-24) or a
  collision-resolved prefix (NS-44) - is a display prefix only (STO-10 `alias`),
  NOT an identity alias: it changes the effective item names but leaves the
  identity and clone at the bare `host/owner/repo`. Managed-policy allowlist
  matching and the compare/browse URLs use the base `host/owner/repo` identity
  (POL-11, LNK-11, CLI-176), never the `@<alias>` form. A source recorded before
  the identity alias existed has only a display `alias` and no identity alias, so
  it keeps its bare identity (no migration, no clone relocation, no change to its
  manifest `source` linkage).
- `STO-59` A source with an identity alias (STO-58) clones at
  `sources/<host>/<owner>/<repo>@<alias>` (the alias is a single safe path
  component, NS-28, so the leaf is filesystem-safe), giving each instance an
  independent checkout so instances pinned to different branches do not share a
  working tree. Because the identity alias is known before the clone, the clone
  lands at this path directly. A source with no identity alias AND no item_path -
  a bare meld, or one whose only prefix is a post-clone display prefix - clones at
  `sources/<host>/<owner>/<repo>` (STO-11) and is the only case that can ever
  share a checkout with another instance (see STO-70 for the item-path leg,
  which also contributes to the leaf and therefore also gets an independent
  checkout).
- `STO-69` A destructive or clone-then-use filesystem operation on a source's
  clone path (`Source::clone_dir`) - a fresh clone, a re-clone that first
  removes the existing directory, or `unmeld`'s cleanup - refuses to proceed
  when the resolved path escapes the managed sources tree: either it contains a
  `ParentDir` (`..`) path component, or, after that check, it does not start
  with `paths.sources_dir()`. The refusal is a structured error naming the
  offending path and the source's identity, raised before any filesystem
  mutation is attempted at that path. This guards a hand-edited or corrupted
  `sources.json` entry (e.g. a `host`/`owner`/`repo` part containing `..`) from
  making `mind` write or delete content outside `~/.mind/sources` - the
  registry-level counterpart to `STO-68`'s revalidation, for the specific
  destructive operations that touch disk at the clone path. The check is
  **lexical** (component/prefix inspection of the path string), not a
  filesystem-resolved (canonicalized) check: a symlink planted inside the
  sources tree that points outside it is therefore out of scope of this check
  and is not caught by it. A linked local source (`Source::is_linked`) is
  exempt: its "clone dir" is the user's own working tree by design (CLI-27),
  not a path under the sources tree, so confining it here would be both
  incorrect and unnecessary.
- `STO-70` The clone-dir leaf (STO-11, STO-59) also incorporates a source's
  `item_path` (LNK-4) when it has one, not just its identity alias, so that an
  item-link instance always gets an independent checkout: two item-link
  instances into the same repo at different paths, and a plain (non-link) meld
  of that repo, previously all resolved to the identical
  `sources/<host>/<owner>/<repo>` clone path, so re-melding a second link
  deleted-and-reprovisioned the first link's (or the plain meld's) checkout out
  from under it, and `unmeld` of either broke every other instance sharing that
  clone. The full leaf formula, combining STO-59's alias suffix with the
  item-path segment:
  - `<repo>` - no item_path, no alias (STO-11, unchanged).
  - `<repo>@<alias>` - alias only (STO-59, unchanged).
  - `<repo>#<enc>` - item_path only.
  - `<repo>#<enc>@<alias>` - both.

  `<enc>` percent-encodes `item_path`: `%` to `%25` first, then `/` to `%2F`.
  This is injective (so two distinct item_paths can never collide on the same
  encoded segment) and stays human-readable in a directory listing; no other
  character needs escaping because an item_path can never itself contain `@`,
  `#`, a `..` component, or NUL (LNK-10, LNK-16). If the encoded leaf would
  exceed 120 bytes, the encoded segment is replaced with the 16-hex FNV of
  `item_path` (`hash::hash_str`) instead, so an unusually deep or long skill
  path still produces a short, filesystem-safe, and still-deterministic leaf.

  **Migration.** Existing clones are never relocated to match the new formula:
  doing so could steal the shared directory out from under whichever registry
  entry legitimately still owns `sources/<host>/<owner>/<repo>` (a plain,
  non-link meld of the same repo, if one is registered). Instead, a source
  instance registered before this change simply now resolves to a leaf that
  has never been cloned there; the existing `.git`-absent branches already
  present in `sync` and `upgrade` re-clone it fresh at the new, isolated path,
  and `introspect` reports it as no-clone in the interim (exactly as it would
  for a source that had never synced). The old shared directory itself is not
  cleaned up by this change; it stays owned by the bare (non-aliased,
  non-linked) instance if one is registered at that identity, or is simply
  orphaned disk usage if every instance that used to share it was a link
  instance (an `introspect`/manual cleanup concern, not something this change
  addresses).
- `STO-60` When a `meld` forks a NEW identity-aliased instance (STO-58) of a repo
  that already has one or more melded instances (any registry entry sharing the
  same base `host/owner/repo`), `meld` prints an explicit one-line note that a new
  instance was registered and the existing one remains, in addition to the
  `melded <name>@<alias>` line. The trailing `@<alias>` is otherwise the only
  signal that a coexisting instance (a second clone, STO-59) was created rather
  than an existing source's display prefix being changed. Suppressed under
  `--json`.
- `STO-63` The STO-60 note names the actual registered `name`(s) of the
  pre-existing instance(s) sharing the base identity, not the bare
  `host/owner/repo` itself. The bare base identity is not necessarily a
  resolvable source: if every pre-existing instance carries an identity alias
  (e.g. melding a repo first as `@a` then as `@b`, with no bare instance ever
  registered), the bare base never appears in the registry and naming it would
  point at a name `unmeld` (or any other verb) cannot resolve. With exactly one
  pre-existing instance the note reads "the existing `<name>` remains"; with more
  than one it reads "the existing instances `<name1>, <name2>` remain".
- `STO-64` `@`/`#` legality in `host`/`owner`/`repo` (CLI-204) is per-part, not a
  blanket rule, because each character's danger is a specific downstream
  collision, not general hygiene:
  - `repo` rejects both `@` and `#`. The identity alias suffix (STO-58) and the
    per-instance clone-dir leaf (STO-59) both append `@<alias>` directly to
    `repo`, and the item-link marker (LNK-4) appends `#<path>` the same way. A
    `repo` value that itself contains `@` or `#` can therefore coincide with
    the identity AND the clone path of a genuinely different, unrelated
    instance: a repo directory literally named `foo@bar` (melded unaliased)
    and repo `foo` melded with an identity alias `bar` (`meld --as bar`) would
    both build the identity `host/owner/foo@bar` and clone at the same
    `foo@bar` leaf, so the second meld would be read back as a re-meld of the
    first (CLI-12) and the two would share one clone on disk.
  - `owner` rejects `#` (new) but keeps `@` legal. `#` in `owner` would place
    the item-link marker before the repo segment, confusing `#`-splitting in
    item refs (`owner/repo#name`) and hook targets (CLI-194). `@` collides
    with nothing in `owner`: the `@<alias>` suffix (STO-58) only ever appends
    to `repo`, never `owner`, so an owner directory legitimately named with an
    `@` (e.g. `/src/proj@v2/agents`) stays admitted.
  - `host` rejects both, unchanged from CLI-204's original statement.
- `STO-17` A source records an optional `roots`: the consumer `--root` override
  (repo-root-relative directories, see DSC-51). Persisted at meld and not changed
  by `sync`. Absent means convention discovery uses `[source].roots` or the repo
  root (DSC-50).
- `STO-55` A source records an optional `add_roots`: the consumer `--add-root`
  roots (repo-root-relative directories, see DSC-84) that compose with the
  source's authoritative discovery layer. Persisted at meld and not changed by
  `sync`. Absent means no additional roots.
- `STO-44` A source records an optional `flat_skills` boolean: the consumer
  `--flat-skills` override (see DSC-75). Persisted at meld and not changed by
  `sync`. Absent or false means convention discovery uses `[source].flat-skills`
  or the `skills/` container (DSC-74).
- `STO-81` An item-link source records an optional `item_kind`: the consumer's
  explicit `--kind` / curator `kind =` value for a file link (LNK-21, LNK-22).
  Persisted at meld and not changed by `sync`. Absent means the kind is resolved
  from the clone on every scan (the containing directory, else the file's
  frontmatter), which is also how every source registered before this field
  reads back.
- `STO-82` A source registered from a curator's list records `curated_by`: the
  identity of the curator (a `[discover].sources` super-source, or a marketplace
  catalog) whose list it came from. Set at registration and not changed by
  `sync`; absent for a directly-melded source. This is the provenance `curate`
  reads to tell "no longer listed" from "never curated" (curate.md CUR-7), so an
  absent value always reads as the latter -- not just for CUR-7's unlisting: a
  source with no `curated_by`, or one owned by a different curator than the
  entry naming it, is never proposed for `install`, `repin`, or the CUR-6
  upgrade sweep either (CUR-12), so a source registered by a binary older than
  this field (which is every currently-curated source for every existing
  consumer, since `curated_by` and `curate` both ship in the same release) is
  fully invisible to `curate` until `curate --adopt <identity>` (CUR-16) claims
  it on a curator's behalf.
- `STO-18` A source records its `pin`: the kind (`follow-branch` | `tag` | `ref`
  | `version`, VER-30) and value (see DSC-41, CLI-17). Persisted at meld and not changed by `sync`. The
  implicit default when unset is `follow-branch` tracking the remote default
  branch.
- `STO-67` `ssh://` userinfo handling in the URL form of `parse_spec` (CLI-19):
  the authority (the segment between `scheme://` and the first `/`) may carry a
  userinfo prefix, `user@host`. It is split on the LAST `@`, not the first, so a
  host containing no `@` of its own splits unambiguously. The part after the
  split is the identity `host` (validated with the same rules as any other host,
  CLI-204) and feeds the source's identity (`host/owner/repo`, STO-13) and clone
  path (STO-11); the FULL authority, userinfo included, is what is written into
  `url`, so `git clone` still authenticates as that user. This applies only to
  scheme `ssh`. Any other scheme (`http`, `https`, and anything else) refuses an
  embedded userinfo outright as `UnsafeRepoSpec` (part `host`, value the full
  authority, reason naming the credential): an http(s) credential in the URL
  would otherwise be persisted verbatim into `sources.json` and echoed back by
  `dump`, unlike an ssh userinfo which is never itself a secret (SSH auth is
  keyed, not password-in-URL). The userinfo itself is validated for empty value
  and control characters before it is trusted into `url`, since the whole
  authority reaches `git clone` unescaped. An authority with no `@` at all (the
  ordinary `ssh://host/owner/repo` form) is unaffected: it parses exactly as
  before this rule existed.
- `STO-72` A local-path repo spec (`/abs/path`, `./rel/path`, `../rel/path`, or
  the local branch of a `file://` item link) is resolved to an absolute path
  BEFORE `owner`/`repo` are derived from it, and that absolute form - not the
  spec as typed - is what is persisted into `Source.url`. Resolution joins the
  process's current working directory onto a relative spec, then resolves
  `.`/`..` path components LEXICALLY (string manipulation only: no
  `canonicalize`, no filesystem stat beyond reading the cwd itself), so
  `parse_spec` stays stat-free and still works for a path that does not exist
  yet. Without this, a relative spec's literal `.`/`..` segment reached the
  owner-directory derivation (which filters `.` and `..`, falling back to the
  `local` placeholder), so `./foo` produced the wrong `local/local/foo`
  identity instead of naming the real parent directory as owner; and the
  literal relative string, recorded verbatim into `url`, stopped resolving to
  the melded directory after any later `cd` (the clone path for a linked local
  source, CLI-27, is `url` itself). Because resolution happens first, an
  input like `/a/b/..` never reaches identity derivation carrying a literal
  `..` segment at all: it resolves to `/a` first, so the identity is built
  from a real, traversal-free path component - a strictly safer outcome than
  refusing the syntax after the fact.
- `STO-73` `Registry::load` migrates a local source's relative `url` to an
  absolute form, using the same resolution `STO-72` applies at parse time,
  once the process cwd is known. This covers a source melded before STO-72
  existed, or a hand-edited registry, that still carries a literal relative
  path. The rewrite happens ONLY when the resolved absolute path currently
  exists as a directory: when it does not, the recorded `url` is left exactly
  as written, so the "linked source is gone" finding (CLI-212, CLI-213) can
  name what the user actually typed rather than a confusing absolutized guess
  for a path that was never real to begin with. Only `url` is ever rewritten,
  never `name`: a source's `name` is the manifest's back-reference key
  (STO-22), and rewriting it would orphan every item installed from that
  source. The rewrite is in-memory only at load time; it is written back to
  disk the next time `Registry::save` runs (e.g. as part of the same
  command's normal write-back), the same as any other in-memory registry
  mutation (mirroring STO-68's revalidation).

## Manifest (manifest.json)

- `STO-20` The manifest maps `kind:effective_name` to an installed item.
- `STO-21` Each installed item records: `kind`, `name` (effective), `bare_name`,
  `source`, `commit`, `hash` (of source content), `store` (path relative to the
  mind root), `links` (absolute symlink paths, one per agent home; a relative
  lobe is resolved to absolute first, see STO-16), `description`,
  `install_hooks` (STO-75, HOOK-110), and `dropped_requires` (LNK-19: empty and
  omitted for an ordinary install; re-derived on every install and upgrade).
- `STO-22` `(source, kind, bare_name)` is the item's stable identity (see
  lifecycle.md). `store` and `links` are its file registry, used by uninstall.
- `STO-23` A missing manifest file is treated as empty.
- `STO-75` An installed item records an `install_hooks` set (HOOK-110), the
  per-item counterpart of a source's `install_hooks` (STO-10's `RecordedHook`
  set, HOOK-55): each entry is an effective install-hook command plus the
  commit it last ran at, or `None` when it was offered and skipped rather than
  run. Absent in a manifest written before this field existed, which
  deserializes as an empty set (no behavior change for an item installed by an
  older binary: its install hooks are simply treated as never offered). The
  set is discarded and rebuilt from scratch whenever the item is (re)installed
  (`learn`, or `upgrade`'s fresh install cycle) -- it is never merged with a
  prior set, so a record tied to an old commit can never suppress a hook at a
  new one. `mind hooks run` (HOOK-102) is the only reader: it filters a hook
  already recorded as run at the item's current commit out of what it offers,
  the item-level mirror of HOOK-101's source-side filter. `forget` removes the
  whole manifest entry, so the record is removed with it.

## Concurrency and durability

mind may be invoked from more than one process at once. State stays consistent
through a single global advisory lock plus atomic file writes; together these
prevent the lost-update and torn-read races a plain read-modify-write would allow.

- `STO-40` A single advisory lock file at `<mind root>/.lock` guards all access to
  mind's persisted state (`sources.json`, `manifest.json`, the store, the links,
  and `config.toml`). A command acquires the lock before it reads state and holds
  it until the command completes, so a mutating command's read-modify-write cycle
  is never interleaved with another process's (and two installs of the same item
  cannot share the `.tmp/staging|backup` scratch). The lock lives under the mind
  root, so a `MIND_HOME` override (e.g. a test's temp home) gets its own isolated
  lock.
- `STO-41` The lock is acquired exclusively by mutating commands (`meld`, `unmeld`,
  `learn`, `forget`, `sync`, `upgrade`, `introspect --fix`, `config lobes add` /
  `remove`) and shared by read-only commands (`recall`, `probe`, `introspect`,
  `config show`). An exclusive holder excludes all others; multiple shared readers
  proceed concurrently but never observe a writer mid-update, so each reader gets a
  consistent cross-file snapshot of the registry and manifest. First-use creation
  of the default `config.toml` (STO-15) is idempotent and written atomically
  (STO-43), so it is safe even when triggered from a shared-lock command.
- `STO-42` Lock acquisition blocks until the lock is available. The lock is
  advisory (it constrains only mind, which always takes it) and is released when
  the holding process exits, including on crash, so an aborted run never wedges the
  next one. A failure to create or lock the file is an `Io` error carrying the lock
  path.
- `STO-43` `sources.json`, `manifest.json`, and `config.toml` are written
  atomically: the new contents are written to a temporary file in the same
  directory and renamed over the target (an atomic replace within one filesystem).
  A reader therefore sees either the old file or the new file, never a partial one,
  and a crash mid-write leaves the previous file intact. This holds independently
  of the lock, so it protects even a lock-less reader.

## `evolve` binary swap

- `STO-45` `evolve` replaces the running executable in place through the
  `self_update` crate, which stages the replacement beside the target (so the
  rename never crosses a filesystem) and swaps it atomically: a failure at any
  point leaves the existing binary intact. An install path the process cannot
  write is `TargetNotWritable` naming the path, and `evolve` asks for that
  answer BEFORE downloading (`check_install_path_writable`), so a
  root-owned-binary refusal costs no download. Only a definite permission
  refusal aborts early; an indeterminate probe result proceeds and lets the
  swap itself report.

  Superseding a previous scheme: `evolve` used to stage the new binary itself
  under `.mind-update.<pid>.<nanos>` in the target's directory and refuse if
  that path already existed (a pre-creation race guard). The staging, the
  same-filesystem rename, and the failure-leaves-the-old-binary property are
  the crate's now, and are tested there rather than here.
- `STO-46` `evolve` holds the global exclusive lock (STO-40) for the entire
  download-and-swap step, serializing concurrent `mind evolve` invocations so
  two processes cannot download and swap over each other.
- `STO-47` Before extracting a release archive, `evolve` verifies it against the
  digest published in that release's `SHA256SUMS` asset
  (`checksum_from_asset("SHA256SUMS")`). The sums asset is fetched before the
  archive, so a release that does not carry one fails without downloading the
  artifact first. A digest mismatch, or a sums file with no entry for the
  archive, is a `DigestMismatch` error and the archive is not extracted --
  neither degrades to an unverified install. Version-pinned `evolve` (`--to V`)
  verifies the pinned release's `SHA256SUMS`.

  GitHub also publishes a per-asset digest in its release API, which the
  updater verifies by default in addition to the above. Both are integrity
  checks served by the same host as the artifact, so neither is a substitute
  for the build-provenance check in STO-66.
- `STO-76` The resolved `evolve` target version -- from an explicit `--to`, a
  managed-policy pin, or a fetched `releases/latest` `tag_name` -- is validated
  as safe to use as a single URL path segment
  (`mindfile::is_plausible_release_tag`) immediately on resolution, before
  anything downstream uses it: the release-tag lookup it drives, the
  confirmation prompt it is echoed into, and the `--json` result. This runs
  before any download-step network call. Without it, a value carrying path segments (e.g.
  `1/../../../../attacker/mind/releases/download/v1`, from a repo/release
  takeover, a TLS-intercepting proxy, or a `--to` value copied from a
  malicious "install these steps" doc) re-points BOTH URLs at the same
  attacker-controlled location wherever a path segment is normalized, so the
  `SHA256SUMS` digest check would compare the attacker's binary against the
  attacker's own digest file and silently pass. A rejected value fails with a
  structured error naming the value (`SelfUpdateInvalidTarget`, a DIFFERENT
  kind from a managed-policy refusal -- see STO-77), refused before any
  network call the download step would make.

  A single leading `v` on the raw `--to` value (or a managed-policy pin) is
  stripped BEFORE this validation runs (and before the `decision` comparison),
  so `evolve --to v1.2.3` behaves identically to `--to 1.2.3`; only one
  leading `v` is stripped, not repeated ones (`--to vv1.2.3` still fails
  validation, since the un-stripped second `v` is not a digit).

  `is_plausible_release_tag` is a purpose-built sibling of
  `mindfile::is_plausible_version` (used for `min-mind-version` and policy
  version pins, spec/discovery.md DSC-40 and spec/policy.md POL-5x), not a
  reuse of it: that validator is digits-and-dots only, which is correct for a
  version pin but is stricter than a release tag needs to be, since it also
  refuses a legitimate semver prerelease/build suffix (e.g. `1.2.3-rc1`).
  Testing a prerelease before promoting it is a legitimate thing to want, and
  since GitHub's `releases/latest` never surfaces a prerelease, an explicit
  `--to` is the only way to reach one, so the release-tag validator accepts an
  IDENTIFIER-LIST grammar, not a bare character class: a dotted-numeric base
  (`\d+(\.\d+)*`) optionally followed by a semver-shaped prerelease suffix
  (`-...`) and/or build-metadata suffix (`+...`), each suffix itself
  `ident(\.ident)*` with `ident = [0-9A-Za-z-]+` -- i.e. EVERY dot-separated
  identifier in a suffix must be non-empty. This is deliberately not the same
  shape as "one run of `[0-9A-Za-z.-]+`" (a bare character class): since `.`
  is itself a member of that charset, a bare-character-class reading would
  also "match" a suffix built of nothing but dots, saying nothing about a
  component between two dots being non-empty. The identifier-list grammar
  rejects `/`, `\`, whitespace, control characters, and -- via the
  non-empty-identifier rule -- any `..` run anywhere in the string, including
  inside the prerelease/build suffix (e.g. `1.0.0-../..`, `1.0.0-..`), which
  would otherwise smuggle a traversal segment (or an ambiguous empty
  identifier) past a naive "digits-and-dots is gone, so a dash is safe"
  reading of the fix.
- `STO-77` Two follow-on corrections to `evolve`'s target-version handling,
  both driven by the STO-76 prerelease grammar:

  - **A malformed target is a distinct error from a policy refusal.** A
    `--to` (or resolved) value that fails STO-76's `is_plausible_release_tag`
    check is `SelfUpdateInvalidTarget`, never `SelfUpdatePolicy`. The two are
    different failures: `SelfUpdatePolicy` (JSON `kind`
    `self-update-policy`) means the managed policy disabled self-update
    (POL-52) or the requested version conflicts with a policy pin (POL-53);
    `SelfUpdateInvalidTarget` (JSON `kind` `self-update-invalid-target`)
    means the value never had a chance to reach policy evaluation at all --
    it is not a plausible version/tag shape. Reporting the malformed-value
    case under the policy kind would read, to a human or a CI log parser
    matching on `kind`, as "the managed policy blocks self-update" when the
    real problem is a bad argument.

  - **A numeric tie between `current` and `target` is resolved by prerelease
    precedence, not assumed up to date.** `decision` (STO-76's caller) treats
    two versions sharing the same dotted numeric base as a "tie". Before this
    fix, the tie was broken in only ONE direction: a prerelease `current`
    moving onto its own (non-prerelease) base `target` offered `Update`.
    Two other same-base pairings fell through to `UpToDate` even under an
    EXPLICIT `--to`, silently doing nothing: two prereleases of the same base
    pinned against each other (e.g. running `0.24.0-rc1`, `--to
    0.24.0-rc2` -- the main reason to pin an rc release at all), and a
    plain-release `current` explicitly pinned onto a same-base prerelease
    `target` (e.g. running the released `0.24.0`, `--to 0.24.0-rc1`). Both
    are now resolved: a prerelease predates its own base release (in either
    direction), and two prereleases of the same base are ordered against
    each other by semver precedence (semver.org precedence rule #11 --
    dot-separated identifiers compared pairwise, a purely-numeric identifier
    compared numerically and always ordering below a non-numeric one, a
    shorter identifier list ordering below one that extends it). The target
    ordering ABOVE `current` is `Update`; ordering BELOW `current` is a
    refused explicit downgrade (`PinnedBelowCurrent`, CLI-147) when
    `explicit` is true, or `UpToDate` when it is not (the non-explicit path,
    a fetched `latest` tag, never carries a prerelease in practice, so this
    arm is a safety net, not a normally-reached case). Byte-identical
    `current`/`target` strings, and two same-base versions differing only in
    build metadata (`+...`, which semver does not order on), stay
    `UpToDate`.
- `STO-65` `evolve --check` (and the run path's equivalent report) names the
  resolved release target triple (`target_triple`, e.g.
  `x86_64-unknown-linux-musl`) alongside the version comparison, so the exact
  artifact that would be fetched is visible before any network call -- e.g. a
  change in artifact resolution (the Linux legs moved from a `gnu` to a `musl`
  build) is caught at `--check` time rather than discovered after the swap.
  Each existing human-readable message keeps its exact original wording as a
  prefix, with `-- target <triple>` appended. Under `--json`, the same value is
  exposed as an additional `target_triple` key; the existing `action`, `target`
  (the version), and `outcome` keys are unchanged -- `--json` consumers depend
  on those names, so a new field is added rather than any key renamed.
- `STO-83` Each release publishes a macOS artifact for both architectures:
  `aarch64-apple-darwin` and `x86_64-apple-darwin`. `evolve`'s `target_triple`
  and `resources/install.sh` resolve Intel macOS to `x86_64-apple-darwin`, and
  the Homebrew formula carries an `on_intel` macOS leg. A release cut before
  this leg existed has no Intel macOS asset, so the always-current install.sh
  fails with a download error against it.
- `STO-71` `resources/install.sh`'s Linux artifact resolution prefers the musl
  release leg (statically linked, so it runs on any glibc, STO-65's rationale)
  and falls back to the gnu leg exactly once if the musl asset download fails.
  This fallback exists because of a specific consistency gap install.sh sits
  in: the script itself is served and fetched straight off the `main` branch
  (`raw.githubusercontent.com/.../main/resources/install.sh`), but the
  *version* it installs is resolved separately, from the latest published
  GitHub release. Those two can disagree about which artifact legs exist for
  that version - `main` can already build (and this script can already know
  how to request) a target whose musl leg the latest release does not carry
  yet, if the musl leg was added to the build matrix after that release was
  cut. Without the fallback, running the always-current installer against an
  artifact-matrix-stale release would hard-fail with a 404 on the musl asset;
  with it, the script degrades to the gnu asset, which every published release
  carries, and installation still succeeds. The fallback triggers only on
  Linux and only when the failed asset was the musl one (so it fires exactly
  once, never loops), and prints a note naming the version and the fallback
  before retrying.
- `STO-74` `resources/install.sh`'s `fetch_to` (the downloader used for the
  release asset and `SHA256SUMS`) requires curl or wget on `PATH`, matching
  `fetch` (the downloader used for the `releases/latest` lookup): both run the
  same `command -v curl || command -v wget || err ...` check and both end the
  script with the same non-zero exit and the same `need curl or wget on PATH`
  message. The *output* is not identical, because of where each is called:
  `fetch_to` is invoked directly in a top-level `if`, so its `err` exits the
  script immediately with that one line on stderr. `fetch` is invoked inside a
  pipeline within a command substitution
  (`tag="$(fetch ... | sed ... | head -n 1)"`), and in `sh`/`dash` each
  pipeline stage runs in its own subshell, so `err`'s `exit 1` there kills only
  that subshell; the script falls through to
  `[ -n "$tag" ] || err "could not determine the latest release; set
  MIND_VERSION"`, so stderr shows that message too, after the real cause. The
  true cause always prints first and is never masked. This matters because
  `fetch_to` can be the *first* downloader call: when `MIND_VERSION` is set,
  the `releases/latest` lookup that goes through `fetch` is skipped entirely,
  so `fetch_to`'s own missing-downloader check is the only thing standing
  between a downloader-less environment and a misleading `download failed:
  <url>` error that hides the real cause.
- `STO-66` `evolve`'s download path soft-verifies the downloaded release
  archive's GitHub build-provenance attestation (`actions/attest-build-provenance`)
  via `gh attestation verify <archive> --repo jaemk/mind` when a `gh` binary is
  present on PATH, mirroring `resources/install.sh`'s `gh attestation verify`
  step. It is wired in as the updater's pre-extraction archive hook
  (`verify_archive`), which is what points `gh` at the downloaded `.tar.gz` --
  the file the release workflow attests (`subject-path: mind-*.tar.gz`) -- and
  not at the binary extracted from it, which has a different digest and no
  attestation of its own. The check runs after the digest checks (STO-47) and
  before extraction, inside the same transactional download-and-swap step
  (STO-46): a failure here leaves the existing binary untouched, exactly like
  every other failure in that step.
  - `gh` absent from PATH: `evolve` proceeds silently, with no note, matching
    install.sh's `if command -v gh` gate exactly.
  - `gh` present but the check could not be attempted (a `gh` build with no
    `attestation` subcommand, or a network-level failure reaching GitHub, e.g. DNS
    or connection failure) -- classified as a TOOLING error, not a statement
    about the artifact: `evolve` proceeds with a note and does not block.
  - `gh` present, the check ran, and it reported a positive result: `evolve`
    proceeds and prints a confirmation.
  - `gh` present, the check ran, and it reported the artifact does NOT verify
    (no matching attestation for the digest, a signer/repo mismatch, or an
    explicit signature failure): `evolve` aborts with `AttestationVerificationFailed`
    before extraction, leaving the existing binary in place.
  - **Difference from `resources/install.sh`:** install.sh treats every
    `gh attestation verify` failure, for any reason, as "could not be verified,
    continuing" (fully soft, never blocks). `evolve` is deliberately narrower:
    the failure classification above treats "no attestations found" (the
    message `gh` also uses for the ordinary, benign case of an artifact
    predating provenance attestations) as a GENUINE failure that aborts, not a
    tooling error that proceeds. This is intentional, not an oversight: an
    attacker who substitutes the release artifact cannot forge a validly signed
    attestation for the new digest, so a substituted artifact surfaces through
    the exact same "no attestations found" wording as a merely-absent
    attestation -- there is no way to tell the two apart from `gh`'s output.
    Treating that ambiguous case as a pass-through tooling error (as install.sh
    does) would defeat the entire point of the check for the one case it exists
    to catch, so `evolve` fails closed on it instead.
  - Failing closed does not strand anyone on a release that predates
    provenance: the download step is reached only on `Decision::Update`
    (CLI-147 refuses a downgrade without downloading), and a release older than
    provenance is by construction older than any binary carrying this check, so
    it never reaches the verification. Every version this check can download is
    one the release workflow attested.
  - **Accepted risk:** the tooling-error classification is a hand-curated list
    of `gh` output markers, so a `gh` version that words a tooling failure
    differently is read as a genuine failure and aborts an upgrade that should
    have proceeded. That is the safe direction (fail closed, with a message
    naming the reason), and widening the list on speculation would trade a
    refused upgrade for a missed substitution. The decision is to leave the list
    as it stands and widen it against a real report rather than a hypothetical
    one. Recorded so it is not re-raised as a finding.
- `STO-48` `evolve` takes NO outer command lock (its `lock_mode` is `None`). It
  acquires the global exclusive lock itself inside the download-and-swap step
  (STO-46), only after the network-free decision/prompt phase, and `evolve
  --check` takes no lock at all. Classifying `evolve` as an outer exclusive
  command would deadlock: the outer guard holds the lock on one fd and the inner
  step then blocks forever acquiring the same lock on a second fd (flock contends
  across two fds in one process, per STO-41/STO-42). This keeps the lock window
  tight and is the fix for the 0.13.0 self-deadlock regression.

## Network fetch timeouts

- `STO-52` `evolve` bounds every request it makes, with a WHOLE-request budget
  (body included), not a connect timeout. Two budgets, because one value cannot
  serve both: a metadata request (the release lookup) gets
  `MIND_HTTP_TIMEOUT_SECS`, 15 s by default, and the artifact download gets a
  600 s ceiling, matching the `--max-time 600` install.sh passes to curl. A
  15 s whole-request budget would abort a healthy multi-megabyte download on a
  slow link, which is why the download does not take the knob's value -- except
  when that value is ABOVE the ceiling, in which case it wins, since raising the
  knob asks for more room and not less. A missing, non-numeric, or zero value
  falls back to 15; zero is treated as missing rather than as "no limit", which
  would defeat the purpose of the knob. `resources/install.sh` applies an
  equivalent 15 s connect timeout and 600 s max-time to its own curl/wget calls;
  it does not read `MIND_HTTP_TIMEOUT_SECS`, because it runs before `mind` is
  installed.
- `STO-53` All wget invocations in `resources/install.sh` pass `--tries=1`. wget
  defaults to 20 retries, so without this flag a blackholed endpoint can take up
  to 20 times the configured timeout before failing. curl is already a single
  attempt bounded by `--max-time 600`. This applies to the install script only:
  `evolve` no longer shells out to a downloader.
- `STO-54` The failure text `evolve` reports is sanitized via `strip_ansi`
  before it is embedded in `DownloadFailed.reason`. A MITM'd or hostile endpoint
  controls part of that text and can inject ANSI escape sequences or Unicode
  bidi override characters to spoof terminal output. The sanitization is applied
  before the proxy-hint logic so the reason field and any appended hint are both
  free of hostile control sequences. The hint names `HTTPS_PROXY` / `HTTP_PROXY`
  and nothing else: git's `http.proxy` never applied, and `~/.curlrc` stopped
  applying when the downloader stopped being curl.
- `STO-78` `evolve` verifies TLS against the MACHINE'S certificate store, not a
  set of roots baked into the binary. `self_update` is built with its
  `native-certs` feature, which puts the ureq client on
  `RootCerts::PlatformVerifier`: the system keychain on macOS, the certificate
  stores on Windows, and on Linux the OpenSSL-convention bundle, which honors
  `SSL_CERT_FILE` / `SSL_CERT_DIR`. Without the feature the client trusts only
  Mozilla's bundled root list and ignores the machine entirely, which breaks
  every network on which an intercepting proxy re-signs outbound HTTPS with a
  company CA -- the CA is installed on the machine and is by construction absent
  from the bundled list, so the handshake fails with `invalid peer certificate:
  UnknownIssuer`. This restores the behavior `evolve` had while it shelled out
  to curl (curl reads the system bundle), which the 0.26.0 rewrite regressed.
  The trade is the reverse failure: on a host whose trust store is empty or
  unreadable (a scratch container), verification now fails where the bundled
  roots would have worked, with `SSL_CERT_FILE` as the escape.
- `STO-79` A certificate-verification failure gets its own hint, distinct from
  STO-54's proxy hint, because the two have different fixes: the proxy hint is
  about REACHING the endpoint (`HTTPS_PROXY`), a certificate failure is about
  TRUSTING it. Recognizing "invalid peer certificate", "certificate verify",
  "unknownissuer", or "self-signed certificate" (case-insensitive, after
  sanitization) appends a hint naming the machine's trust store and
  `SSL_CERT_FILE`, so the message points at the corporate-CA case (STO-78) that
  produces it rather than leaving a bare rustls string. The hints are mutually
  exclusive and the proxy hint is checked first: a 407 means the request never
  got far enough to present a certificate.
- `STO-57` `evolve`'s GitHub API request carries an authorization token when the
  environment supplies one (`GH_TOKEN`, else `GITHUB_TOKEN`, matching the `gh`
  CLI's documented precedence; the first set and non-empty value wins, trimmed).
  This moves the caller out of GitHub's unauthenticated per-IP rate limit (60/hr,
  shared across a NAT egress and easily exhausted on a workplace network, where
  the API answers HTTP 403) into the authenticated 5000/hr tier. The token is
  attached only to requests on the configured API host, never forwarded to the
  artifact CDN across a cross-host redirect. With no token set the request is
  unchanged. Both the lookup and the host gating are the `self_update` crate's
  (`auth_token_from_env`), and are tested there.

  This supersedes a previous scheme in which `evolve` read the two variables
  itself and passed the header to curl through a 0600 `--config` file (so the
  token stayed off argv and out of `/proc/<pid>/cmdline`), rejecting a token
  carrying a control character, `"`, or `\` because either could break out of
  that file's quoted-string syntax. There is no config file and no argv to keep
  a token off now. The precedence also flipped: `GITHUB_TOKEN` used to win over
  `GH_TOKEN`, and the `gh` order is what applies. A token that cannot be encoded
  as an HTTP header value is an error at request time rather than a warning and
  an unauthenticated retry, so a malformed `GITHUB_TOKEN` (a CI expression that
  expanded wrong, say) fails `evolve` instead of silently degrading it. That is
  the crate's behavior and is accepted rather than worked around: the failure
  names the token, and an ambient credential that is broken is worth surfacing.
- `STO-80` A GitHub request `evolve` makes that the endpoint REFUSES -- a rate
  limit (HTTP 429, or a 403 reporting a spent quota or a `Retry-After`) or an
  authorization failure (a bare 401/403) -- is its own error,
  `SelfUpdateRefused` (`--json` kind `self-update-refused`), not a
  `DownloadFailed`: nothing was downloaded, and the URL that failed is the API
  endpoint rather than the release page `DownloadFailed` names. It renders as
  two lines, a status line and a hint line:

  ```
  GitHub rate limited this request: HTTP 403 from https://api.github.com/repos/jaemk/mind/releases/latest (retry in 40m11s)
  hint: the request went out unauthenticated, and GitHub's anonymous budget of 60 requests/hour is counted per source IP, so a NAT'd or proxied network shares one budget. Set GH_TOKEN or GITHUB_TOKEN for the authenticated 5000/hour budget, e.g. GH_TOKEN=$(gh auth token) mind evolve
  ```

  The status line names the status, the endpoint, and the wait when the response
  supplied one (`Retry-After`, else the quota reset); an unknown wait is omitted
  rather than reported as zero. The wait is rendered in units rather than as a
  raw second count, at most two of them, largest first (`40m11s`, `1h1m`, `45s`):
  a primary limit resets up to an hour out, so the raw form is four digits the
  reader has to divide before it means anything, and seconds are noise once there
  is an hour to report. A zero component is omitted, not padded (`1h`, never
  `1h0m`). The hint is chosen by what the environment
  actually supplies. With NO token set (`GH_TOKEN`/`GITHUB_TOKEN` unset, empty,
  or whitespace, matching the lookup's own trimming) it is the text above: this
  is the case STO-57 provides for but never advertises, and on a corporate
  network the per-IP budget is spent by other people entirely, so the remedy is
  stated in a runnable form rather than as a variable name. With a token ALREADY
  set the hint does not tell the user to set one: a rate limit becomes "that
  token's own budget, retry once it resets" and an authorization failure becomes
  "the token was rejected, check that it is valid" -- opposite fixes, so they are
  not merged.

  The message is composed from the error's own accessors (status, url, and the
  folded `Retry-After`/reset wait) rather than from its `Display`, which closes
  every rate limit with a generic "set an auth token to raise the limit": beside
  the unauthenticated hint that says the same thing twice, and beside the
  token-set hint it contradicts it. The endpoint-supplied URL is passed through
  `strip_ansi` before it is embedded (STO-54). This hint replaces rather than
  supplements STO-54's proxy hint and STO-79's certificate hint, which are about
  reaching and trusting the endpoint rather than about being let through by it.
- `STO-61` Removed, superseded by STO-57. Stated the curl `--config` file that
  carried the bearer token off argv; there is no curl invocation to keep a token
  off the command line now. The number is retained so it is not reused.
- `STO-62` Removed, superseded by STO-57. Stated `evolve`'s fail-closed handling
  of an unsafe token value and its fail-open handling of a temp-file write
  failure, both properties of the curl config file STO-61 described. The number
  is retained so it is not reused.

## Schema versions

- `STO-50` Both `sources.json` and `manifest.json` carry a top-level `"version"`
  field with value `1`. A reader that finds a version greater than `1` fails with
  a `StateTooNew` error rather than silently misinterpreting the file. A missing
  `"version"` field is treated as `1` (backward compatibility with files written
  before this field existed).
- `STO-51` A `StateTooNew` error names the file (`"sources.json"` or
  `"manifest.json"`), the version found, and the highest version supported, and
  advises the user to run `mind evolve` (the binary self-update verb; `upgrade`
  is the item verb, so the remedy names `evolve` explicitly), or, if `evolve`
  itself reports up to date, to use the newer `mind` binary that wrote the
  file instead. The second clause covers the realistic trigger for this error:
  a locally built or newer-than-release `mind` wrote the state, so `evolve`
  cannot supply a newer release to fix it, and the first clause alone would
  strand the user with every command failing and no next step.

## Per-project lobes

- `STO-56` A lobe receives links only while its parent directory exists (the
  reachability gate). The leaf `skills/` directory is created on link, but a
  missing parent is never fabricated: a moved or deleted project lobe contributes
  no links and is not recreated by `introspect --fix`. The gate is checked at
  the reachability-sensitive write sites (the install fan-out's `planned_links`
  and `relink`) and not inside `agent_homes()`, which returns the complete
  configured list so uninstall confinement and `~/.claude` auto-create-on-link
  remain correct. It is intentionally NOT checked at `link_into_new_lobes`: that
  path backfills links into a newly added lobe (e.g. a preset base such as
  `base/.gemini/config`) whose parent may not exist yet, so gating there would
  suppress the very links it exists to create.

## Errors

- `STO-30` Filesystem failures carry the offending path (`Io { path, source }`).
- `STO-31` Malformed `sources.json` or `manifest.json` is a `Json` error naming
  the file.