# Discovery
How a source's installable items are found and described. The catalog is source
truth: it holds bare names; prefixing and token expansion are install-time
transforms (see namespacing.md).
## Precedence
Three layers, in order:
- `DSC-1` Convention discovery is the zero-config default: any melded repo is
scanned with no manifest required.
- `DSC-2` Per-item frontmatter is always read for an item's description.
- `DSC-3` A `mind.toml` at the repo root is optional. `[source]` metadata is read
whenever the file is present. If it declares `[[items]]` or `[discover]` item
globs it becomes authoritative and convention discovery is skipped for that
source. A bare `[discover].sources` list (no item globs) does not disable
convention.
A fourth, external-manifest layer reads Claude Code's native plugin manifests
(`.claude-plugin/marketplace.json`, `.claude-plugin/plugin.json`) as a discovery
input for repos published to the built-in plugin system. It feeds the same catalog
and leaves the install model unchanged; an authoritative `mind.toml` (DSC-3) still
wins over it. A co-present `mind.toml` composes at a finer grain: own-item
directives (`[[items]]`, `[discover]` item globs, `roots`, `flat-skills`) suppress
the manifest's own-item layer, while a `[discover].sources` list layers a curated
super-source on top of the manifest (MKT-15, MKT-16). See
[marketplace.md](marketplace.md) (MKT-1..16).
## Convention
- `DSC-10` A skill is a directory `skills/<name>/` containing `SKILL.md`; its name
is the directory name.
- `DSC-11` An agent is a file `agents/<name>.md`; its name is the file stem.
- `DSC-12` A rule is a file `rules/<name>.md`; its name is the file stem.
- `DSC-14` A command is a file `commands/<name>.md`; its name is the file stem.
The scan is flat, as for agents and rules (commands.md CMD-1, CMD-2).
- `DSC-13` A missing `skills/`, `agents/`, `rules/`, `commands/`, `workflows/`,
or `tools/` directory yields no items (not an error).
- `DSC-108` The scan classifies every entry by symlink metadata, not by
following the link, so a symlink cannot make an absent file appear present, an
unrelated file's size be reported, or an unrelated file's metadata be
republished. A symlinked entry is not discovered as an item, whatever it points
at. The rule covers every classification the scan makes, not one kind's:
- a single-file kind's entry (`agents/<name>.md`, `rules/<name>.md`,
`commands/<name>.md`, `workflows/<name>.js`);
- both halves of the skill shape: the `skills/<name>/` directory AND its
`SKILL.md` anchor;
- a `tools/<name>/` directory, which has no anchor file, so its own
classification is the whole test of whether the item exists;
- the kind container itself (`skills/`, `agents/`, `workflows/`, ...): a
container that is a symlink is not listed, so the scan never walks a
directory outside the source;
- the same components read from a Claude plugin or a marketplace entry's
in-repo plugin (marketplace.md MKT-3, MKT-14, MKT-18, workflows.md WF-40),
including a leaf skill directory an entry's `skills` array names directly;
- a `[discover]` glob match (DSC-80), which matches by name and so would
otherwise be a second route to the same answers. Such a match is dropped
silently, as the convention scan drops such an entry; a match whose link
resolves outside the repo root remains DSC-81's hard error, which takes
precedence.
A symlinked entry that is therefore not an item is also not counted as a
mapped plugin component: it is reported among the unmapped entries (MKT-4),
since what the scan leaves behind must not be described as installed.
This matters because the scan runs over a tree the source controls entirely.
Following links would let a repo of `workflows/<probe>.js` symlinks aimed at
absolute paths on the consumer's machine answer "does this path exist" per
entry through the item listing, and would let `review`'s workflow size report
print the byte count of a file outside the repo. A symlinked `SKILL.md` is
worse than an existence bit: the anchor is the file whose frontmatter becomes
the item's description, so following it republishes a linked-to file's
`description:` through `recall` and `probe`. It also matches what install would
do with such an entry: the copy walk rejects a symlink at or inside an item
tree outright (lifecycle.md LIFE-42), so an item the scan declines to offer
here was never installable, and discovering one could only ever produce an
offer `mind` cannot honor.
The rule is about DISCOVERY. An explicit `[[items]]` entry names its own path,
and a declared path that is a symlink is not refused at scan time (the author
wrote it); it fails at install, where LIFE-42 names the symlink.
## Frontmatter
- `DSC-20` An item's description is the top-level `description` from the YAML
frontmatter of its `SKILL.md` (skill) or its `.md` (agent, rule, command).
- `DSC-21` The frontmatter reader handles a leading `--- ... ---` block with
top-level scalar keys and surrounding quotes, including block scalars (DSC-22).
Flow collections and nested mappings are not interpreted. No frontmatter yields
no description.
- `DSC-22` The frontmatter reader interprets a top-level block scalar value: a
folded scalar (`>`, `>-`, `>+`) or a literal scalar (`|`, `|-`, `|+`) introduced
by the indicator after the colon. The value is the run of more-indented lines
that follow, ending at the first line that dedents to or below the key's
indentation or at the closing `---`. A folded scalar joins its lines with single
spaces (a blank line is a paragraph break); a literal scalar preserves its line
breaks; the chomping indicator (`-` strip, `+` keep, none clip) governs trailing
newlines. The result is trimmed for display in `recall`/`probe`. Other nested
structures and flow collections remain uninterpreted.
- `DSC-23` The frontmatter reader strips a leading UTF-8 BOM (`\xEF\xBB\xBF`,
U+FEFF) from the file text before checking for the `---` opening delimiter.
A file authored on Windows or by an editor that emits a BOM still has its
frontmatter read correctly.
- `DSC-94` An item's description (DSC-20, from frontmatter, or a `[[items]]`
override, DSC-32) has ANSI escape sequences, control characters, and
security-blocked Unicode code points (bidi overrides, directional marks,
zero-width characters) stripped at the single catalog capture point
(`build_item`), the same class of sanitization the plugin-manifest discovery
path (marketplace.md) already applies to its own description fields. This
makes every display surface (`recall`, `probe --no-tui`, `--json`, which
otherwise only ANSI-escapes but does not strip a bidi override) safe by
construction rather than depending on each call site remembering to sanitize
before printing. A source `mind.toml`'s `[source].description` (below) is
sanitized the same way, at every point it is read into the registry (meld,
sync, pin/freeze).
## mind.toml
```toml
[source]
description = "..." # optional; shown by recall --sources
prefix = "jk" # optional; namespace (see namespacing.md)
min-mind-version = "0.2" # minimum mind version; enforced at scan/meld (DSC-40)
roots = ["packages/tools"] # optional; convention scan roots (DSC-50)
flat-skills = true # optional; skills are bare dirs at a root, no
# skills/ container (DSC-74)
follow-branch = "main" # optional; pin directive, one of
# follow-branch / pin-tag / pin-ref (DSC-41)
[[items]] # explicit inventory (authoritative)
path = "skills/review" # relative to repo root; a dir for skills
link = "rules/x.md" # optional; link target relative to ~/.claude
description = "..." # optional; overrides frontmatter
[discover] # glob discovery (authoritative for items)
skills = { include = ["packages/*/SKILL.md"], exclude = ["packages/internal-*/SKILL.md"] }
agents = { include = ["agents/*.md"] }
rules = { include = ["rules/*.md"] }
# A curated super-source: list other repos to meld recursively.
sources = [
{ source = "owner/repo" },
{ source = "github:foo/bar", as = "fb" }, # impose a namespace on the nested source
{ source = "owner/recommended", install = true } # offer this one for install on meld
]
# Adopt an un-onboarded source: supply config it lacks (applied only when it has
# no mind.toml of its own). The array-of-tables form carries hooks:
[[discover.sources]]
source = "owner/unonboarded"
on-auth-failure = { action = "skip", message = "..." } # optional; skip if auth fails (DSC-68)
follow-branch = "main" # pin directive for the nested source (DSC-41)
roots = ["packages/agents"] # scan roots for the nested source (DSC-50)
flat-skills = true # flat skill layout for the nested source (DSC-77)
[[discover.sources.hooks]] # build hooks for the nested source (HOOK-50)
run = "make build"
```
- `DSC-30` Unknown top-level or table fields are rejected (the file is strict).
- `DSC-31` A `[[items]]` entry with an unknown `kind` is an error (`MindToml`).
- `DSC-109` A declared `workflow` item's path must name a file, not a directory;
a directory-shaped workflow entry is refused at scan time. The refusal is a
`MindToml` error naming the item and its path. A workflow is exactly one `.js`
file (workflows.md WF-1), and that file is also the metadata the scan reads
(WF-4), so a directory has nothing to read, would install a whole tree at a
link target the harness tries to load as a script, and would report a
directory's size in `review`. The author wrote the path, so this is a hard
error rather than a skipped item, matching DSC-31's treatment of a declared
entry that cannot be honored.
- `DSC-71` A `[[items]]` `name` must be a single safe path component: it is
rejected (`MindToml`) when empty, equal to `.` or `..`, or containing a path
separator (`/` or `\`) or a NUL byte. The name indexes the store
(`store/<kind>/<name>`) and the per-home link (`<home>/<dir>/<name>`), so this
stops a melded source from steering either path outside its kind directory.
(Convention and `[discover]`-glob names are derived from a single filesystem
component, so they cannot carry a separator and are inherently safe.)
- `DSC-95` An item's bare NAME is source-controlled (a filename or a `[[items]]`
value) and keys identity, so -- unlike a description (DSC-94) -- it cannot be
sanitized at the catalog capture point without changing what the item is.
Instead every human/`--json` print site renders the name through a sanitizing
display accessor (`display_key`/`display_name`/`display_effective_name`, and
`InstalledItem::sanitized_for_display` for a whole serialized item), so an
ANSI/control/bidi code point in a name is stripped before it reaches a
terminal, exactly as the TUI already sanitizes at its model boundary (TUI-60)
and `review` sanitizes a finding message (CLI-224); the raw `key()`/`name`
stays intact for identity, store, link, and manifest matching. Separately,
every source-controlled string a scan-time error message echoes back (a
`[[items]]` name/link/path, a `[discover]` glob pattern, a matched path in a
confinement error, and the annotated snippet a TOML parse error quotes from
the file it failed to read, root manifest or item manifest alike) is sanitized
(DSC-94 rule) before it reaches stderr, so a rejection cannot itself carry an
injection payload. The parse-error snippet is not an edge case: a raw ESC byte
anywhere in a TOML file is itself a parse error, so quoting the offending line
is the guaranteed path for that byte, and with DSC-98 it is reachable from
`recall`/`probe`, verbs the user did not point at any particular source. A
path is sanitized on the same terms as the text: a clone directory carries
source-controlled components.
Caveat: sanitizing only at display means a sanitized name may not round-trip
as an item ref -- but only for a name captured by a binary predating
DSC-96 (a legacy manifest entry). `mind recall`/`probe` show the
display-sanitized `skill:review`, but `mind forget skill:review` matches on
the raw name, so if that item's raw name actually carries a stripped code
point (e.g. `revie<ZWSP>w`), the ref a user copies from `recall`'s output
does not resolve and `forget` reports "not installed". DSC-96 closes this
for anything installed by a current binary: it refuses a control/bidi/
zero-width name at capture, so such a name can no longer exist to diverge
between display and identity, and the ref a user copies round-trips.
Mechanism: this guarantee is enforced by the type system for the DEFAULT
formatting path, not by call-site discipline alone. `InstalledItem::key()`,
`CatalogItem::key()`, and `UnmanagedItem::key()` return an `ItemKey`
newtype (`sanitize::ItemKey`, `pub(crate)` in a crate with no lib target),
not a plain `String`. `ItemKey` carries no `Display` impl, so
`println!("{}", key)` / `format!("{key}")` / `{}` inside a `--json` struct
via `#[derive(Serialize)]` on a `String` field is a compile error, not a
lint or a review-time habit a new print site can silently skip. That is the
only thing withholding `Display` buys: `ItemKey::as_str()` (the raw
identity reading -- map/set lookups, comparisons, a manifest key, a path
component) and `impl From<ItemKey> for String` (also raw, e.g. to key a
`BTreeMap<String, _>`) remain as explicit, named escape hatches, and each
still needs the same call-site discipline this type otherwise removes from
every default-formatting print site -- neither is "the" accessor that
produces a printable string; `ItemKey::display()` is the one accessor that
sanitizes, and reaching for it instead of `as_str()`/`.into()` at a given
print site is a choice the compiler does not check. `ItemKey` still derives
`Debug` (safe: `Debug for str` escapes a control character, so `{:?}`
cannot forge a terminal escape sequence the way `Display` could) and has no
`Serialize`/`Deserialize` impl of its own -- every persisted or `--json`
site goes through `.as_str()` or `Into<String>` first -- so the on-disk
manifest format (a bare `kind:name` object key) is unchanged by this type.
- `DSC-96` An item's bare NAME (DSC-71's single-path-component rule) is also
rejected when it contains a control character or a security-blocked Unicode
code point (a bidi override, a directional mark, or a zero-width character --
the same class `namespace::is_safe_prefix_component` rejects for a prefix,
NS-72). Name identity is raw (it keys the store path, the symlink, and the
manifest, and the cross-source collision check compares it raw, NS-43), while
DSC-95 sanitizes only at display; without this rule a hostile source could
ship a name (e.g. a skill directory `revie<ZWSP>w/`, or a declared
`[[items]] name = "revie<ZWSP>w"`) that renders identically to a trusted
item's name in `recall`, `probe`, the TUI, and the `learn` confirmation while
installing as a distinct item, defeating both the visual check a user makes
before approving an install and the DSC-95/NS-43 collision warning (neither
of which would fire, since the raw names differ only by an invisible or
direction-altering code point). Two capture points, two consequences for the
same rule:
- A `[[items]]` declared `name` failing this check is a hard `MindToml`
error (the author wrote the name explicitly, so it is treated like any
other DSC-71 violation).
- A convention- or `[discover]`-glob-derived name (an arbitrary filename in
the source tree, not an author-declared value) failing this check is
skipped with a stderr warning instead of failing the whole scan, so one
hostile filename does not make an otherwise-good repo un-meldable; every
other item in the source is still discovered.
- `DSC-98` An item directory's own `mind.toml` (install-hooks.md HOOK-131) that
cannot be read as an item manifest -- it does not parse, or it carries a key
outside `[[hooks]]`, or one of its hooks names an unknown event -- drops THAT
ITEM from the catalog with a sanitized stderr warning naming the file and the
one-table rule. It does not fail the scan. The item is dropped rather than
treated as hook-free, which preserves HOOK-131's guarantee that a malformed
manifest never degrades to "this item declares no hooks": an item that is
never offered can run no hook, so this degradation is safe on the verbs that
execute hooks (`meld`, `learn`, `upgrade`, `hooks run`) as well as on the
read-only ones. The scan-wide alternative is what makes this necessary: item
manifests are read on every catalog walk, so a single stray file (a vendored
sub-source, a skill that ships a sample manifest, a monorepo matched by a
`[discover].skills` glob) would otherwise take down `recall`, `probe`,
`learn`, and `upgrade` for EVERY melded source, on verbs where no hook could
ever run and with no opt-out. Same shape as DSC-96's name skip and CLI-212's
dead-source skip: the narrowest thing that is broken is what stops working.
It applies to a `[[items]]`-declared item too, since the manifest it cannot
read is the item's own, not the one that declared it. A root `[[items]]` entry
that declares hooks is authoritative (HOOK-132) and the item manifest is never
read at all, so it cannot be dropped for one.
- `DSC-99` An item whose source has NO effective namespace prefix
(namespacing.md) but whose bare name contains `:` is warned about, naming the
item and its source. The name is still installed as written: `:` is
legal in an item name and commands.md CMD-2/CMD-6 recommend it for a command
group (`commands/frontend:build.md`), so it cannot be rejected. But a prefix
component may not contain `:` (NS-72), which is what makes an unprefixed
source shipping `commands/acme:deploy.md` a forgery risk: it installs
`acme:deploy`, the exact spelling a source prefixed `acme` would produce for
its `deploy`, and no later surface can tell the two apart by name alone. The
warning is the detection the identity cannot provide; the source column in
`recall`/`probe` and the cross-source collision check (NS-43) are what a user
reads after it.
- `DSC-101` The DSC-99 warning is emitted by the verbs at which the user decides
to trust a source -- `meld` (over the offered inventory) and `learn` (over the
install closure, `--dry-run` included) -- and by `review` as a
`forged-namespace` advisory. The catalog scan itself only computes the
predicate and never writes it: `catalog::scan` runs on every read-only surface,
and the `probe` TUI re-scans on its ~1s poll tick (TUI-15), so a warning
emitted from the scan repeats forever and is written straight into the
alternate screen, corrupting the display. Read-only surfaces (`recall`,
`probe`, `introspect`) therefore emit nothing; the source column they already
show is what distinguishes the two spellings there.
- `DSC-102` The scan's remaining degraded-state warnings -- a linked source whose
directory is gone (CLI-212), an item manifest that will not parse (DSC-98), an
unsafe item name skipped by the convention scan (DSC-96), and a registry entry
dropped by revalidation on `Registry::load` (STO-68) -- are muted for as long
as the TUI owns the terminal, and restored when it gives it back. Each reports
a persistent condition, so on the ~1s poll tick (TUI-15) it is not a one-time
notice but a line written into the alternate screen every second. Muting is a
live process-wide toggle the TUI sets, not a property of the output context
(which resolves once from the global flags): the same process runs the TUI and,
through it, the verbs. Outside the TUI every one of these still prints.
- `DSC-72` A `[[items]]` `link` override (the link target relative to an agent
home) must be a safe relative path: it is rejected (`MindToml`) when empty,
absolute, beginning with `~`, containing a `..` (parent) component, or
containing a NUL byte. So a melded source cannot place a symlink outside the
agent home (e.g. `link = "../../.bashrc"`). DSC-97 narrows this further: the
target must also stay inside a kind directory.
- `DSC-97` A `[[items]]` `link` override must, beyond DSC-72's escape-safety
rule, have its first path component name one of the kind directories
(`skills/`, `agents/`, `rules/`, `commands/`, `workflows/`, `tools/`);
anything else is rejected (`MindToml`) at catalog scan, before install ever
runs. `commands/` and `workflows/` carry an extra condition: only an item of
that kind may name the directory, since a file in either is not content the
harness merely offers, it is the slash command `/<name>` (commands.md CMD-1)
or a workflow the harness runs (workflows.md WF-8). Without that condition a
source could declare `kind = "rule", path = "rules/style.md", link =
"commands/deploy.md"` and have its prose become `/deploy` in a consumer's
agent home, including for a consumer who filtered their install to rules. Without this, DSC-72
alone accepts any relative path inside the agent home, including its root:
a source declaring `link = "settings.json"` (or a harness's own hooks/config
path) and shipping matching content installs, via an ordinary `learn`, as a
symlink at that root path. On the Claude harness that turns attacker-chosen
item content into `~/.claude/settings.json` (or the equivalent inside a
`--local`/`link-project` project lobe), which the harness reads as its own
settings on the very next tool use, including a `hooks` key that runs an
arbitrary command -- with no `mind` hook-consent prompt involved, since this
path never touches the hooks machinery (hook.rs). `ensure_unoccupied`
(install.rs) only refuses when the target already exists, so a fresh
`~/.claude`/project lobe is not protected by that guard, and `--force`
(LIFE-41) stashing a pre-existing file aside does not help either; the fix
has to happen before either of those, at catalog validation. Confining the
first component to a kind directory closes both the fresh-lobe and the
`--local`/snapshot-lobe (which copies a real file rather than symlinking)
variants at once, without narrowing TOOL-4's documented use case (a tool
surfaced under a *different* kind directory than its own, e.g.
`link = "agents/detect"` on a `kind = "tool"` item): TOOL-4 only ever needed
a different kind directory, never an arbitrary path, so nothing legitimate
is lost. There is no consent-based escape hatch (contrast the hook
disclosure/prompt machinery): the risk (planting a harness's own config) is
judged severe enough, and the confined form flexible enough (any name,
subdirectory nesting, and any of the four non-command kind directories, not
just the item's own kind), that a bypass is not offered. `mind review`
(CLI-130..133)
discloses every item's custom `link` target as an advisory finding
(`custom-link`) alongside the hooks and risky references it already reports,
so a consumer sees where each item will symlink before melding; a link that
fails this confinement surfaces instead as a hard `scan-error` finding (the
meld would already refuse it), not a `custom-link` advisory.
- `DSC-73` A `[[items]]` `path` must be a safe repo-root-relative path: it is
rejected (`MindToml`) when empty, absolute, beginning with `~`, containing a
`..` (parent) component, or containing a NUL byte. A relative value free of
those components may contain `/` for subdirectories (e.g.
`guidelines/style.md`). This stops a melded source from reading host files
outside its clone via the copy-to-store step (e.g.
`path = "../../../../etc/passwd"` or `path = "/home/victim/.ssh/id_rsa"`).
The same safety rule (`is_safe_link_rel`) that validates `link` (DSC-72)
applies to `path`.
- `DSC-32` An item's description is its `mind.toml` `description` if given, else
its frontmatter description.
- `DSC-33` Each `[discover]` kind (`skills`, `agents`, `rules`, `commands`,
`workflows`, `tools`) is a table with
`include` and optional `exclude` glob lists, relative to the repo root. A skill
glob matches a `SKILL.md` (the item is its parent directory); agent, rule,
command, and workflow globs match the item FILE directly; a tool glob matches
the tool directory (TOOL-7). Every kind's globs count equally: any non-empty
`include` list makes the file authoritative (DSC-3) and every pattern is
confinement-checked at load (DSC-81).
- `DSC-34` `[[items]]` and `[discover]` may both appear; their results are unioned.
- `DSC-35` A source with only `[source]` metadata, or only `[discover].sources`
(no item globs), still uses convention discovery for its own items.
- `DSC-36` A repo with no `mind.toml` is unaffected by all of the above.
- `DSC-37` Within a kind, `include` globs are matched first, then any path also
matched by an `exclude` glob is dropped from the result.
- `DSC-38` `[discover].sources` lists other repo specs (each parsed like a `meld`
argument). Melding a source recursively melds each listed source, skipping any
already registered, so a `mind.toml` can act as a curated registry /
super-source. Each curated source is registered independently and tracks its
own upstream commit. Recursion always terminates, even when a nested source is
itself a super-source and the chain forms a cycle (`A -> B -> C -> A`): a source
is registered before its own nested sources are processed, and a source already
seen is skipped, matched both by URL within the run and by `host/owner/repo`
identity against the registry (so two spellings of the same repo do not slip
past). Each source in the transitive set is therefore processed at most once.
- `DSC-39` A `[discover].sources` entry may set `as = "<prefix>"` to impose a
namespace on that nested source (equivalent to `meld --as`).
- `DSC-100` A `[discover].sources` entry whose `source` is an item link
(item-link.md LNK-2) may set `kind = "<agent|rule|command>"` to declare what
the linked file is (equivalent to `meld --kind`, CLI-239). It is the first
step of the LNK-21 resolution order, so it outranks the containing directory
and the file's frontmatter. A `kind` on an entry whose `source` is not an item
link, or one naming a kind the link's shape cannot be (`skill`/`tool` on a
file link, anything but `skill` on a directory link), is `LinkKindMismatch`
(LNK-21).
- `DSC-92` A `[discover].sources` entry's `source` spec is parsed exactly like a
`meld` argument (DSC-38), so it may itself be a local-path spec (`./rel/path`
or `../rel/path`). When it is, `MindToml::load` rewrites that relative path to
an absolute one against the DIRECTORY IT READ THE `mind.toml` FROM, not
against whatever directory the consumer running `meld`/`sync` happens to be
in. Without this, a curated entry like `source = "../explicit"` (the shape
`examples/super-source/mind.toml` uses) resolves relative to the consumer's
cwd - almost never the curator's intended sibling directory - so the same
super-source melds a different (or no) nested source depending on where the
consumer happens to be standing. Fixed at the read site (`MindToml::load`
itself knows the directory it read from) rather than at each use site, so it
covers both callers that read a curator's entries uniformly: `meld`'s initial
curated-source walk and `sync`'s DSC-57 re-walk resolve nested local sources
against the same, correct base with no per-caller change needed. (`dump` is
not one of them: it never reads a curator's `[discover].sources`, it rebuilds
the list from the registry, where every local source already carries its
absolute path, STO-72.) An absolute nested `source` (or a remote spec) is
unaffected.
- `DSC-93` The absolute entry wins. A `[discover].sources` entry whose DSC-92
resolution lands inside mind's own managed sources tree
(`<mind_home>/sources`) at a path that does not exist is skipped with a stderr
warning and a `SkippedEntry` (`reason = "unresolvable_local_path"`), instead of
being attempted, failing to clone, and counting toward the DSC-80 curator
guard. That combination only arises for a relative entry in a CLONED curator:
DSC-92 resolves it against the directory the `mind.toml` was read from, which
for a clone is `<mind_home>/sources/<host>/<owner>/<repo>`, so `../nested`
names a sibling clone dir mind never created. The reference meant something in
the curator's own working tree and nothing here, and under DSC-80 a curator
with no items of its own would turn that dead reference into a hard failure of
the entire meld. It is specifically a `dump` reproduction that suffers: the
dump carries a correct absolute entry for that same nested source (DUMP-1), so
the source IS reachable, but the curator's own re-walk reaches the broken
relative reading first and aborts before the absolute entry is reached. A
LINKED curator is unaffected (its `mind.toml` is read from the user's working
tree, so its relative entries resolve there), as is a mistyped relative path in
any curator whose resolution lands outside the sources tree: that still fails
as DSC-79/DSC-80 describe. The predicate is structural, not name-based: mind is
the sole writer of the sources tree and never creates the sibling such an entry
needs, so a non-existent path there cannot be anything but a misresolved
relative reference. Identity (`host/owner/repo`) is deliberately not consulted,
because the two readings of the same nested source resolve to different owners.
- `DSC-58` A `[discover].sources` entry may set `install = true` (default false)
to recommend that nested source for install: melding the super-source offers its
items via the same preview-and-prompt as the top-level source (CLI-23, honoring
`--yes` and skipped under `--link-only`), rather than leaving them only
registered and available (DSC-54). The flag is per entry, so a curator chooses
which nested sources install by default and which stay available. It applies on
a fresh meld and a re-meld, and the whole chain is still traversed so a deeper
`install = true` is reached even under an unflagged parent. `meld --recursive`
(DSC-55) is the superset: it installs every nested source regardless of the flag.
- `DSC-62` A `[discover].sources` entry may set `install-items = ["skill:name",
...]`, a list of bare `kind:name` refs that scopes install to exactly those of
the nested source's items, instead of `install = true` (offer all, DSC-58) or
`install = false` (offer none). When the super-source is melded and the entry is
reached by the install flow (DSC-54, DSC-55), only the listed items are offered
via the same preview-and-prompt as the top-level source (CLI-23, honoring
`--yes`, skipped under `--link-only`); the source's other items are registered
and left available. `install-items = []` is equivalent to `install = false`.
When `install-items` is present it governs the entry's install behavior; when it
is absent the `install` boolean governs as before (DSC-58).
- `DSC-63` Refs in `install-items` (DSC-62) are bare `kind:name` in source truth;
a prefix in effect for the entry (`as`, DSC-39) is applied at install. A ref
naming an item the nested source does not offer is an error (`BadReference`) at
meld, not a silent skip.
- `DSC-64` Setting `install = true` together with a non-empty `install-items` on
the same entry is a `MindToml` error: offering all and offering a named subset
are mutually exclusive. The subset form is expressed by `install-items` alone
(with `install` left unset or false).
- `DSC-59` A `[discover].sources` entry may carry configuration for the
nested source that the source itself would normally declare in its own
`mind.toml`: a pin directive (exactly one of `follow-branch = "<branch>"`,
`pin-tag = "<tag>"`, `pin-ref = "<commit>"`, or `pin-version = "<range>"`,
per DSC-41 and VER-110), `roots = [...]`
(convention scan roots, DSC-50), `add-roots = [...]` (additive convention
scan roots, DSC-84; gated like `roots`, see DSC-88), `flat-skills = true`
(flat skill layout, DSC-74; see DSC-77), and one or more hooks as a
`[[discover.sources.hooks]]` array-of-tables (the `[[hooks]]` shape, HOOK-50).
Declaring more than one pin directive on an entry is a `MindToml` error, the
same one-of rule a `[source]` section follows (DSC-41). This lets a curator add
support for a source that has not onboarded itself (no `mind.toml`), including
one with custom build requirements or a monorepo layout, without forking it,
and lets a generated super-source pin each source to a reproducible revision
(DSC-65, DUMP-1).
- `DSC-60` The curator-supplied `roots`, `add-roots` (DSC-88), `flat-skills`, and
`hooks` (DSC-59) apply only when the nested source ships no `mind.toml` of its
own. When the nested source has a `mind.toml`, that file is authoritative for
its roots, add-roots, flat-skills, and hooks and the curator-supplied values
are ignored (a warning is emitted, since the source has onboarded). The gate is
whole-file: a nested `mind.toml`, even one that does not declare
roots/add-roots/flat-skills/hooks, suppresses all four. The curator-supplied
pin is NOT gated: it is authoritative regardless of the nested `mind.toml`
(DSC-65). `as` (DSC-39) and `install` (DSC-58) are registry/consumer concerns
and are likewise unaffected by this gate; they always apply.
- `DSC-61` A curator-supplied entry behaves as if the source had declared the
same in its own `mind.toml`: when applied (the DSC-60 gate permits, i.e. the
nested source ships no `mind.toml`), `roots` governs convention discovery
(DSC-50) and the supplied hooks run under the same disclosure and safety prompt
as a source's own hooks (HOOK-50..60), including the non-TTY skip and
`--dangerously-skip-install-hook-check`. The curator-supplied pin always
applies (DSC-65): it resolves and is recorded as the source's pin directive
(DSC-41), so `sync` tracks it. A consumer's explicit `meld` pin flag still
overrides a curator-supplied pin (DSC-41 precedence).
- `DSC-65` A pin directive on a `[discover].sources` entry (DSC-59) is
authoritative: it sets the nested source's pin whether or not the source ships
its own `mind.toml`, overriding the source's own `[source]` pin directive
(DSC-41). It is exempt from the DSC-60 fallback gate, which governs only
`roots`, `add-roots`, `flat-skills`, and `hooks`; a nested entry that supplies
only a pin (no gated roots/add-roots/flat-skills/hooks) does not trigger the
DSC-60 "ignored" warning. The precedence is: a consumer's direct top-level
`meld` pin flag wins (DSC-41), then the entry's pin directive, then the
source's own `[source]` pin directive, then the default branch. `dump` relies on
this to reproduce each melded source at its recorded commit by emitting a
per-entry `pin-ref` (DUMP-1, DUMP-4).
- `DSC-88` A `[discover].sources` entry's `add-roots` (DSC-59, mirroring the
consumer `--add-root` flag, DSC-84) is gated by DSC-60 exactly like `roots`,
`flat-skills`, and hooks: it applies only when the nested source ships no
`mind.toml` of its own, and is otherwise ignored with the DSC-60 warning. A
direct consumer `--add-root` (DSC-84) is never gated, and the asymmetry is
deliberate: a consumer melding a source directly makes their own
export-control decision for their own install, while a curator's entry acts on
a nested source on the consumer's behalf. Ungated, a curator could set
`add-roots = ["."]` on an entry for a source whose author shipped an
authoritative `mind.toml` exporting a subset of its items (DSC-3), reaching
every item the author did not export, and installing all of them when the
entry is also `install = true` (DSC-58). `dump` emits a nested entry's
`add-roots` as it emits `roots` (DUMP-11), so re-melding a generated
super-source applies the same gate.
- `DSC-54` Melding a super-source (one whose `mind.toml` lists `[discover].sources`)
registers the whole nested chain (DSC-38), but the post-meld auto-install flow
(CLI-23) runs only over the super-source's OWN items (`<source>#*`) plus any
nested source the curator marked `install = true` (DSC-58): the remaining nested
discovered sources are registered and their items are left available, not
installed. A super-source that ships its own items still offers them for install
like any source; a purely curated registry installs nothing by default unless it
flags an entry `install = true`.
- `DSC-55` `meld --recursive` (`-r`) extends the auto-install flow to EVERY nested
discovered source, beyond the curator's `install = true` defaults: each source in
the curated chain has its items offered for install via the same
preview-and-prompt as the top-level source (honoring `--yes`). It applies both on
a fresh meld and on a re-meld of an already-registered super-source: on a re-meld
the chain is already registered, so its items are installed without
re-registering. Without the flag only the top-level source's items and the
`install = true` entries are offered (DSC-54, DSC-58). `--link-only` (register,
install nothing) takes precedence: combined with `--recursive` it still installs
nothing.
- `DSC-56` After a successful `meld` of a source that declares `[discover].sources`,
`mind` prints a one-time advisory note pointing the user to `mind probe` to
browse and search what the newly registered sources offer, so a curated registry
is discoverable right after melding. The note prints after the install step.
- `DSC-57` `sync` re-walks each registered source's `[discover].sources` from its
refreshed `mind.toml` and melds any newly-listed nested source not already
registered, register-only (the DSC-54 default, never auto-installing nested
items) and cycle-safe by the DSC-38 guards, so a curated registry picks up
sources added upstream without a re-meld. A nested source removed from the list
is left registered (removal stays an explicit `unmeld`): `sync` only adds.
- `DSC-66` Pin/ref values supplied in a `mind.toml` `[source]` or
`[[discover.sources]]` pin directive (`follow-branch`, `pin-tag`, `pin-ref`)
are validated at parse time before any git subprocess is invoked. A value that
is empty, begins with `-`, contains ASCII whitespace, contains control
characters, or contains `..` is rejected with `MindError::InvalidRef`. This
prevents argument injection: a malicious or misconfigured `mind.toml` shipped
by a melded super-source cannot inject git options (e.g.
`--upload-pack=touch /tmp/pwned`) into a child `git` process. At the git call
layer, a `--` end-of-options terminator is inserted before every positional
ref/branch/tag/sha argument and before the repository URL in `git clone`
invocations, so that git cannot interpret a value as an option even if
validation were bypassed. The `prefer_ssh` rewrite applies to both `https://`
and `http://` remotes so that a plain-HTTP clone URL is not left unrewritten
when SSH is preferred.
- `DSC-40` When a source's `[source].min-mind-version` is greater than the
running `mind` version, melding or scanning that source is an error
(`IncompatibleVersion`) rather than proceeding against a format it predates.
Versions compare by dotted numeric component (a missing component is 0, so
`0.2` == `0.2.0`). The `min-mind-version` field value is validated at parse
time (`MindToml::load`): it must be a non-empty string of one or more
dot-separated components where every component is non-empty and consists
solely of ASCII decimal digits (e.g. `"1"`, `"0.7"`, `"2.3.1"`). A value
with an empty component, a non-digit character, or an empty string is
rejected with a `MindToml` error naming the field and the bad value (e.g.
`"0.3-beta"`, `"abc"`, `""`, `"1.x"` are all rejected). The running
binary's own version string (from the build environment) may carry a
pre-release segment (e.g. `0.2.0-rc1`); such a segment compares as 0 in
the version comparison only. A scan of a source whose declared version the
binary does not meet proceeds with a warning, printed once per source per
run, that names both versions, says the source may not work as expected, and
points at `mind evolve`. `sync`, `recall`, and `probe` therefore keep working
on a source the user chose to meld anyway; whether to meld it at all is
decided at meld time (DSC-116). `review` still reports it as a hard
`incompatible-version` finding (CLI-132).
- `DSC-41` `[source]` may declare a pin: exactly one of `follow-branch = "<branch>"`,
`pin-tag = "<tag>"`, `pin-ref = "<commit>"`, or `pin-version = "<range>"`
(VER-51). It is read from the source's
default-branch `mind.toml` and supplies the default pin when the consumer gives
no `--pin` (or deprecated alias) flag at meld (CLI-17); a
consumer flag overrides it. Declaring more than one is a `MindToml` error. (See
CLI-18 for clone behavior and CLI-55 for how `sync` treats each pin kind.)
## Scan roots
By default convention discovery (DSC-10..12) scans the repo root. A monorepo, or
a repo whose agent tooling lives in a subtree, can point the scan at one or more
subdirectories instead.
- `DSC-50` `[source].roots` is an optional list of repo-root-relative directories.
When set, convention discovery scans for `skills/`, `agents/`, `rules/` under
*each* listed root rather than at the repo root. Unset means a single implicit
root of the repo root (the DSC-10..13 behavior, unchanged). An explicitly empty
list (`roots = []`) is distinct from unset: it scans zero roots and so
discovers nothing.
- `DSC-51` `meld --root <dir>` (repeatable) overrides `[source].roots` entirely:
convention discovery scans only the consumer-specified roots, letting a consumer
narrow a broad source to exactly the subtree they want. The override is persisted
on the source (STO-17) and applied by later scans and `sync`.
- `DSC-52` Scan roots affect convention discovery only. An authoritative `mind.toml`
(one declaring `[[items]]` or `[discover]` item globs, DSC-3) keeps its
repo-root-relative paths and ignores `roots`; if `--root` is passed for such a
source, `meld` prints a note that it is ignored. A `--root` or `[source].roots`
path that is not a directory in the clone is an error (`InvalidRoot`).
- `DSC-53` When scanning multiple roots, results are unioned. Two roots that yield
the same kind and bare name within one source is an error (`DuplicateItem`),
since an item's identity is `(source, kind, bare_name)` and the collision could
not be installed unambiguously. The same uniqueness check applies to explicit
`[[items]]` declarations: two entries with the same kind and name in a
`mind.toml` are a `DuplicateItem` error.
## Additive roots
`--root` replaces a source's discovery layout (DSC-51) and, on a manifest
source, suppresses the manifest (MKT-15). A consumer who wants the declared
inventory AND more - a marketplace catalog that lists only a subset of the
repo's skills, an authoritative `mind.toml` that exports only some items -
needs roots that compose instead of replace.
- `DSC-84` `meld --add-root <dir>` (repeatable) adds convention scan roots that
compose with whatever discovery layer is authoritative for the source instead
of replacing or suppressing it. Each added root is convention-scanned in both
skill layouts at once - the `skills/` container (DSC-10) and flat bare-name
directories (DSC-74) - since the `SKILL.md` anchor disambiguates them and a
`--flat-skills` override would suppress a manifest (MKT-15); agent, rule, and
tool discovery under the root is the DSC-11..13 convention. The results union
with the layer's items: a `.claude-plugin/` manifest's items
(MKT-2/MKT-14, see MKT-17), an authoritative `mind.toml`'s declared items
(DSC-3), or the ordinary convention scan (where the added roots simply extend
the effective root set, DSC-50/DSC-51). Unlike `--root`, `--add-root` is not
an own-item layout override: it does not suppress a manifest (it is not in
the MKT-15 directive set) and it is not ignored for an authoritative
`mind.toml` (contrast DSC-52). The roots are persisted on the source (STO-55)
and applied by later scans and `sync`. Validation matches `--root`: a value
that is absolute, escapes the clone, or is not a directory in the clone is
`InvalidRoot`.
- `DSC-85` De-duplication and collisions for added roots: an add-root item
whose on-disk path is already contributed by the authoritative layer (e.g. a
marketplace in-repo plugin's skill directory that also sits under an added
root) is skipped - the authoritative entry wins, keeping its namespace and
metadata. After the union, the uniqueness rule applies to the source's whole
offering by (kind, effective name): a distinct-path collision between an
add-root item and an authoritative-layer item, or between two add-root items
at DISTINCT on-disk paths (DSC-53), is a `DuplicateItem` error. A same-path
overlap between two add-root items de-duplicates instead (DSC-87).
- `DSC-86` Add-root items are ordinary convention items: bare names derived
from the layout, with the layer's effective prefix applied at install. For a
single-plugin source (`plugin.json`) with no explicit override, that is the
plugin-name default prefix (MKT-5), so the plugin's items and the added
roots' items share one namespace. A marketplace's per-entry namespaces
(MKT-8) do not reach add-root items: there is no single entry to attribute
an added root to, so they carry only the source's own prefix (alias or
`[source].namespace`).
- `DSC-87` Two added roots can surface the SAME on-disk item AS THE SAME KIND
through different scan mechanisms (e.g. `--add-root . --add-root skills`,
where the `.` root finds `skills/foo` as a Skill via the `skills/` container
pass and the `skills` root finds the same directory as a Skill via the
flat-skills pass). This is a same-path overlap, not a name collision: among
the items an add-root scan itself contributes, only the first occurrence of a
given (kind, canonical on-disk path) pair is kept and later duplicates are
silently dropped, before the DSC-85 (kind, effective-name) uniqueness check
runs. `DuplicateItem` is reserved for a same-name collision between items at
distinct on-disk paths.
- `DSC-89` The DSC-87 dedup key is `(kind, canonical path)`, not the path alone:
a single on-disk path can contribute up to one item PER KIND. This matters for
a directory that qualifies as more than one kind at once -- a `tools/<name>/`
directory needs no anchor file (DSC-13), so one that also carries a
`SKILL.md` is both a valid Tool and a valid Skill. With `--add-root .
--add-root tools`, the `.` root's tools/ pass finds it as a Tool and the
`tools` root's flat-skills pass finds the same directory as a Skill: two
DIFFERENT kinds at the same path, so DSC-87 does not apply to this pair and
both survive (each still subject to the DSC-85 (kind, effective-name)
uniqueness check independently). Keying DSC-87 on path alone (pre-DSC-89)
silently dropped whichever kind's scan pass ran second.
## Flat skill layout
By default a skill is a directory under a `skills/` container (DSC-10). A source
whose skill directories sit directly at a scan root, with no `skills/` container,
can opt into flat skill discovery instead of having to spell out a
`[discover].skills` glob.
- `DSC-74` `[source].flat-skills` is an optional boolean (default false). When
true, convention discovery finds a skill as a bare-name directory containing a
`SKILL.md` directly under a scan root (`<root>/<name>/SKILL.md`), taking the
directory name as the bare skill name, rather than requiring the `skills/`
container (DSC-10). The scan is shallow: only the immediate child directories of
each root are checked for a direct `SKILL.md`. It composes with `roots` (DSC-50):
with `roots` unset the single implicit root is the repo root, so flat discovery
scans `<repo>/*/SKILL.md`; with `roots` set it scans each listed root. Flat
discovery applies to the skill kind only: the `SKILL.md` anchor disambiguates a
skill directory from an arbitrary one, whereas agent and rule items are bare
`.md` files and tool directories carry no required anchor, so a bare directory at
a root cannot be classified for those kinds. Agent (`agents/`), rule (`rules/`),
and tool (`tools/`) discovery under each root is unchanged. False or unset is the
DSC-10 container behavior. Flat discovery changes only the discovered on-disk
path and the derived bare name; an item's stable identity, store path, and link
target are unchanged (a flat skill `<root>/foo/` installs and links identically
to a containered `skills/foo/`), so install, link, uninstall, drift, and the
`recall`/`probe` kind categorization (the item's `kind` is assigned at scan, not
inferred from its source path) need no layout-specific handling.
- `DSC-75` `meld --flat-skills` force-enables flat skill discovery for the source:
it turns the flat layout on even for a source that did not declare
`[source].flat-skills`. The flag is one-directional (there is no
`--no-flat-skills`): the effective setting is the consumer override OR the
source's own `[source].flat-skills` (STO-44, DSC-74), so a consumer cannot
disable a source's declared flat layout (which would make its skills
undiscoverable). It is persisted on the source (STO-44) and applied by later
scans and `sync`. Like `--root` (DSC-51), it is set at the initial meld; a
re-meld of an already-registered source does not change the persisted value
(unmeld and meld again to change it). Like `roots`, it affects convention
discovery only.
- `DSC-76` Flat skill discovery affects convention discovery only. An authoritative
`mind.toml` (one declaring `[[items]]` or `[discover]` item globs, DSC-3) keeps
its explicit paths and ignores `flat-skills`; if `--flat-skills` is passed for
such a source, `meld` prints a note that it is ignored (mirroring the DSC-52
treatment of `--root`).
- `DSC-77` A `[discover].sources` entry may set `flat-skills = true` to declare
that a nested source uses the flat skill layout, alongside the other
curator-supplied configuration a source would normally declare in its own
`mind.toml` (`roots`, hooks, a pin; DSC-59). Like `roots` and hooks, it is
applied only when the nested source ships no `mind.toml` of its own; when the
nested source has a `mind.toml`, that file is authoritative and the
curator-supplied `flat-skills` is ignored with a warning (the DSC-60 gate). This
lets a curator adopt a flat-layout source that has not onboarded itself without
forking it.
- `DSC-78` A `[discover].sources` entry uses the key `namespace` (not `as`) to
declare the alias to impose on the nested source; `as` is retained as a
backward-compatible alias for `namespace` but `namespace` is the canonical form.
`dump` always emits `namespace =` (never `as =`) so round-tripped manifests use
the canonical key. `effective_alias()` returns the `namespace` value when present,
falling back to the legacy `as` value, so both keys work at meld and sync time.
## Authentication failure handling for nested sources
A curator may list sources that require authentication (private GitHub repos,
self-hosted forges). By default a git auth failure during meld or sync is a hard
error indistinguishable from any other network error. The `on-auth-failure` entry
field lets the curator opt in to named handling.
- `DSC-67` *(removed: `private = true` flag was dropped before implementation in
favor of the inline-table form in DSC-68)*
- `DSC-68` A `[discover].sources` entry may set `on-auth-failure` as an inline
table with a required `action` key and an optional `message` key.
`action` must be `"error"` or `"skip"`. `message`, when present, is a plain
string shown to the user alongside the standard auth-failure line (DSC-69).
Without `on-auth-failure`, an auth failure is a generic git error (hard
error, non-zero exit). Authentication
failure is detected by matching the following credential-denial patterns in
the git subprocess stderr (case-insensitive): `authentication failed`,
`permission denied (publickey)`, `could not read username`,
`could not read password`,
`the requested url returned error: 401`,
`the requested url returned error: 403`, `invalid username or password`,
`invalid credentials`, `http basic: access denied`,
`fatal: unable to authenticate`. Detection depends on English-language git
stderr; under a non-English locale (`LANG`/`LC_ALL` not `en`), patterns may
not match and the entry degrades to the generic hard-error path. Patterns
that conflate access denial with a missing repository (e.g. `repository not
found`) are intentionally excluded. The same handling applies during `sync`,
which re-walks `[discover].sources` through the same path (DSC-57); a
skipped nested source is warned and left unregistered, and
`action = "error"` fails the sync. Setting `action = "error"` vs. omitting
`on-auth-failure` entirely: both exit non-zero on auth failure, but
`"error"` uses the standardized DSC-69 message format and supports the
optional `message` field; omitting `on-auth-failure` produces a raw generic
git error with no custom message. With it, the action governs: `"error"`
still exits non-zero; `"skip"` emits a warning and continues. The source is
not registered and any transitive chain reachable only through it is also
skipped.
```toml
[[discover.sources]]
source = "owner/private-repo"
on-auth-failure = { action = "skip", message = "Configure credentials: https://example.com/auth" }
```
- `DSC-69` When `on-auth-failure` is set and auth fails, `mind` always prints
`"unable to meld source <source> due to authentication failure"` (where
`<source>` is the parsed short name, the `name` field derived from
`parse_spec`, not the full URL or spec string the curator wrote), with
`" (skipping)"` appended when `action` is `"skip"`. If `message` is set, it is
printed on the line immediately following. Both the source name and the
curator `message` have ANSI escape sequences and non-printable control
characters stripped before display, preventing terminal injection from
curator-controlled content. Under `"error"`, the message (if any)
is printed before the process exits non-zero. Under `--json`, skipped entries
appear as objects in a `"skipped"` array on the outer mutation result, each
object carrying `"source"` and `"reason": "auth_failure"`. No separate JSON
object is emitted per skipped source; the outer result is one JSON object
total, consistent with CLI-153.
- `DSC-70` `on-auth-failure` governs only the direct clone failure of the entry
itself; auth failures from transitive descendants (sources nested within the
entry's own `mind.toml`) propagate as hard errors regardless of the entry's
policy. The implementation detects descendant failures by checking whether the
entry's source is already present in the registry at the point the auth failure
arrives: a registered entry cloned successfully, so the failure originates from
a deeper level. The same scoping applies during `sync` re-walk.
- `DSC-79` — A `[discover].sources` entry whose clone fails for a non-auth reason (network
error, not-found, etc.) is skipped with a stderr warning rather than hard-failing the meld;
the primary source and every successfully-cloned nested source remain registered. The skip is
surfaced in the `SkippedEntry` list (`--json` `skipped[]`) with `reason = "clone_failure"`.
Scoping follows DSC-70: a failure arriving after the entry is already in the registry
originates from a descendant and propagates unchanged. The same skip applies during `sync`
re-walk.
- `DSC-80` — When the primary source is exclusively a curator (a catalog scan of its own
directory yields zero items) and every nested `[discover].sources` entry fails to register,
the meld hard-fails: registering a source with zero discoverable items is not useful. A
primary source with at least one of its own items, or with at least one nested source that
registered, succeeds (exit 0). The primary/top-level source's own clone failure is a hard
error regardless (unchanged).
## Glob confinement and name safety
- `DSC-81` A `[discover]` glob pattern must be a relative path: a pattern that
is absolute or contains a `..` path component is rejected at `mind.toml` parse
time with a `MindToml` error. Every filesystem path produced by expanding a
`[discover]` glob is canonicalized and checked to lie within the clone root; a
match outside the root is an error (mirrors the DSC-73 treatment of explicit
`[[items]]` paths). Glob-discovered item names are validated with the same
safe-component rule as `[[items]]` names (DSC-71): an empty, `.`, `..`, or
separator-bearing name is rejected with a `MindToml` error.
- `DSC-83` For skill globs in `[discover]`, the bare skill name is the parent
directory name (not the `SKILL.md` file stem). The safe-component check
(DSC-81) is applied to the parent directory name so that a skill directory
whose name contains a path separator or NUL is rejected at discovery time,
consistent with how `[[items]]` names are validated (DSC-71).
## Metadata size cap
- `DSC-91` Every read of a source-controlled **metadata** file is size-capped at
a ceiling that defaults to 8 MiB (`METADATA_SIZE_LIMIT` in `src/error.rs`) and
is configurable per invocation (DSC-103),
shared by every metadata reader through one helper
(`error::read_capped_metadata`) so the limit and the refusal are defined
exactly once. "Metadata" here means the hand-authored files a maintainer
edits directly to describe a source or an item, not the item's own content:
`mind.toml` (`mindfile::MindToml::load`), an item's frontmatter block read
from `SKILL.md`/`TOOL.md`/an agent or rule `.md` file
(`frontmatter::description_capped`/`file_field_capped`, used by
`catalog::build_item` for every field it derives from an item's meta file:
description, `install`/`uninstall`, `bin`/`build`, `requires`), and a Claude
plugin manifest (`.claude-plugin/plugin.json` /
`.claude-plugin/marketplace.json`, `plugin_manifest::load_plugin_manifest` /
`load_marketplace_manifest`).
The read is refused, with `MindError::MetadataTooLarge` naming the file and
the limit, before the file is read in full: the underlying helper reads at
most `METADATA_SIZE_LIMIT + 1` bytes via `Read::take`, so an oversized file's
cost to `mind` is bounded by the cap rather than by the file's actual size. A
file at or below the cap is read and behaves exactly as before; the cap does
not otherwise change parsing or validation. An absent file is unaffected (a
metadata reader that already treats "file not found" as "absent", e.g. an
optional `mind.toml` or an optional `TOOL.md`, keeps that behavior; only the
new size-cap failure is a hard error).
8 MiB is chosen because these are hand-authored text files: the largest
`mind.toml`/`SKILL.md` in this repo's own examples is a few KB, and any
legitimate metadata file is expected to stay well under 100 KB. 8 MiB sits
orders of magnitude above that while still bounding how much memory a single
melded source can force `mind` to allocate while scanning or installing it:
narrow enough to matter, wide enough that no real-world metadata file is
expected to ever approach it.
## Configuring the metadata cap
- `DSC-103` The DSC-91 cap is a default, not a fixed property of the format. An
operator sets the ceiling for an invocation with the global
`--max-metadata-size <SIZE>` flag (CLI-240), for an environment with
`MIND_MAX_METADATA_SIZE`, or persistently with the `max-metadata-size` key in
`~/.mind/config.toml`. The resolved value is installed once, before any verb
dispatches, and every metadata read in that run uses it: the knob is the same
one helper DSC-91 names, so no reader can be capped differently from another.
Absent all three, the default applies and behavior is exactly as before.
Raising it exists for a source with a legitimately large metadata file, which
DSC-91 does not deny is possible (a generated `mind.toml`, a marketplace
catalog of hundreds of plugins, a workflow whose whole `.js` body is its
metadata, WF-55). Lowering it is equally supported, and is the reason the knob
is not simply an `--allow-large-metadata` boolean: an operator melding sources
they do not trust can bound the allocation further than the default does.
- `DSC-104` Precedence, highest first: `--max-metadata-size`, then
`MIND_MAX_METADATA_SIZE`, then the config key, then the DSC-91 default. Only
the highest-precedence value present is parsed; a lower one is not consulted,
and never silently repairs a higher one that is invalid (DSC-105). An empty or
whitespace-only `MIND_MAX_METADATA_SIZE` reads as unset rather than as an
invalid value, matching the shell convention that `VAR=` clears a variable.
The config file is read only when neither the flag nor the environment
supplied a value, and a `config.toml` that will not parse falls back to the
default cap rather than failing the run: commands that never read the config
(`evolve`, `hooks`, `completions`, `man`) must not start failing on a
malformed one, and the commands that do read it report the parse error
themselves, in their own context. The exception is a malformed file that
names the `max-metadata-size` key: config would have been the cap's origin,
so the parse error is reported rather than the default applied silently.
- `DSC-105` An accepted size is a whole number of bytes (`16777216`), a
binary-suffixed size (`32MiB`, `512KiB`, `2GiB`, and the bare `32M`/`512K`/
`2G` spellings, which are binary), a decimal-suffixed size (`16MB` =
16000000), an explicit `512B`, or one of `unlimited`, `none`, or a zero value
for no ceiling (DSC-110). Case and internal whitespace are not significant. A
fraction (`1.5MiB`) is refused rather than rounded, so a value that cannot be
represented exactly is never silently changed to a different one. The refusal
says the value is fractional and names a whole-unit equivalent, rather than
reporting the text after the decimal point as an unknown unit.
A value that is present but unparseable is a hard `MindError::BadMetadataSize`
before any other work, naming which of the three origins supplied it. It is
never a fallback to the default: an operator who typed a cap and silently got
the default instead would have no way to tell.
- `DSC-110` Every zero-valued spelling of the cap means "unlimited", whatever
unit it carries: `0`, `00`, `0B`, `0KiB`, `0MiB`, `0GB`, and the same with
internal whitespace or different case all resolve to no ceiling, identically
to `unlimited` and `none`. A cap of literally zero bytes would refuse every
metadata file, including the source's own `mind.toml`, so it is not a value
an operator can mean.
The rule lives in the one size parser, so every origin (the flag,
`MIND_MAX_METADATA_SIZE`, the config key) and every caller of it agree on
what a zero means. It is not a rescue applied by one caller after the fact:
that split gave a direct caller "refuse everything" for `0B` while the CLI
read the same text as "unlimited". The accepted-spellings list in
`--max-metadata-size --help` and in the configuration docs names this same
set.
- `DSC-111` `MIND_MAX_METADATA_SIZE` is distinguished from unset by presence,
not by decodability. A variable that is set but is not valid UTF-8 is a
present-and-unparseable value: it is a hard `MindError::BadMetadataSize`
naming `MIND_MAX_METADATA_SIZE` as the origin, per DSC-105, not a silent
fall-through to the config key or the default. Only an absent variable, or
one whose value is empty or whitespace-only (DSC-104), reads as unset.
The refusal is subject to precedence (DSC-104), not ahead of it: it applies
when the environment is the origin the run would have used. An explicit
`--max-metadata-size` outranks the variable, and the undecodable value is then
dropped without being examined, exactly as a junk-but-decodable one is (only
the winning origin is ever parsed). Otherwise the flag would override the
variable or not depending on which bytes it happened to hold.
Under `--json` the refusal is the CLI-181 error envelope on stdout
(`"kind": "bad-metadata-size"`), like any other error: it is raised before
dispatch, which is where a bare text line on stdout would otherwise break
CLI-217's one-document contract.
- `DSC-106` The cap in effect appears in `MindError::MetadataTooLarge` (so the
refusal names the ceiling the operator can change, and its remedy names the
flag) and in `config show` (CLI-241). Both report the *effective* value, not
the config key, since the flag and the environment outrank it.
- `DSC-107` `unlimited` removes the bound, which re-enters the DSC-90 accepted
risk for metadata files: a source can then make `mind` allocate as much as its
largest metadata file. That is the operator's decision to make for a source
they chose to meld, and it is the reason the default stays 8 MiB rather than
being raised to accommodate the rare large file. The bounded-read guarantee
itself is unchanged at every other setting: the reader still takes at most
cap-plus-one bytes, so the cost of an oversized file is bounded by the cap and
not by the file.
## Accepted risks
- `DSC-90` Beyond the metadata cap (DSC-91), no other read of source-controlled
content is size-capped, and TOML nesting depth is not bounded. This covers
every read of an item's own *content* (as opposed to the metadata describing
it): every file in an item tree during `{{ns:}}` expansion at install; the
unguarded-reference scan; `review`; the TUI preview; and content hashing. A
source author could ship a multi-gigabyte file inside a skill's directory, or
a deeply nested `mind.toml` table structure, to make `mind` allocate heavily
while installing or reviewing that source.
This is a self-inflicted denial of service: the operator chose to meld that
source, no privilege boundary is crossed, and the exposure is a crash of the
current invocation only, not a persistent or remote compromise. The cost of a
fixed cap on arbitrary item content, a false ceiling that could someday
reject a legitimately large file inside a skill, is judged higher than the
benefit at the current adoption scale. The decision is to accept the residual
risk and revisit if melding untrusted third-party sources at scale becomes
common.
Normative content: `mind` does not cap these reads, and does not bound TOML
nesting depth. A change that adds a cap to any of them is a behavior change
and must update this entry (and, if it narrows the scope further, split out
another `DSC-9x` the way DSC-91 was split out of this one). Recorded with an
ID, indexed from the feature table, and referenced from `SECURITY.md`,
because the prior unlabeled version of this note was missed by a reviewer
looking for exactly it.
## `[source].namespace`
- `DSC-82` The `[source]` table accepts both `namespace` and `prefix` for the
namespace prefix field. Both keys name the same field; `namespace` is the
canonical key (written by `init-source` per INIT-11, matching the nested-entry
key per DSC-78 and the `--namespace` flag per CLI-159) and `prefix` stays
accepted indefinitely as a deprecated parse alias. When a `mind.toml` carries
`prefix =` and is rewritten by `init-source`, the `prefix =` line is replaced
with `namespace =` so only one key is present after the update.
- `DSC-112` The reserved-prefix list (namespacing.md NS-25, NS-29) is
append-only and grows when a word becomes an item kind, so a source can be
melded under a prefix that is legal at meld time and reserved afterwards
(`workflow`, once the workflow kind shipped). What the melded source then
meets depends on which of the two ingresses set the prefix:
- A consumer-set prefix (`meld --namespace`, recorded as the registry entry's
alias) is never re-validated, so it stays in effect. The scan warns once
per source that the prefix is now a reserved word and that `<prefix>:<name>`
reads as a kind-qualified ref, and names re-melding with `--namespace
<other>` as the fix. It is advisory: the items are already installed under
that prefix, and failing every scanning verb over a naming problem would
take the whole lobe down.
- A source-declared `[source].prefix` is validated at every `mind.toml` load
(NS-25), so the refusal is a hard error on any verb that scans the source
(`recall`, `probe`, `learn`, `upgrade`, `introspect`). For a source that is
already melded, that error names the source and `mind unmeld <source>` as
the remedy, rather than the pre-meld wording that the prefix "cannot be
used": the value is the source's own declaration, so no consumer-side flag
overrides it and the only action available is to drop the source.
- `DSC-113` `completions` and `man` read no source metadata, so they skip
metadata-cap resolution entirely (DSC-104). A malformed `--max-metadata-size`,
`MIND_MAX_METADATA_SIZE`, or `max-metadata-size` config value therefore never
stops a shell from loading its completion script or a user from reading the
manual. Every other verb resolves the cap and refuses an undecodable value.
- `DSC-114` A `[discover].workflows` glob matches only a regular, non-symlink
`.js` file, as the convention scan and a declared item (DSC-108, DSC-109)
do. A match that is a directory, a non-`.js` file, or a symlink is dropped
silently, not offered; a symlink resolving outside the clone stays the
DSC-81 hard error.
- `DSC-115` A skill or tool anchor is classified no-follow wherever it is
discovered. An item link (item-link.md) to a skill directory requires the
directory and its `SKILL.md` to be a real directory and a regular file, not
links. A tool's `TOOL.md` is optional, but when present (convention scan or
`[discover].tools` glob) it must be a regular non-symlink file; a tool whose
`TOOL.md` is a symlink or other non-file is not offered.
- `DSC-116` Before registering a source whose `[source].min-mind-version` the
running binary does not meet (DSC-40), `meld` prints the DSC-40 warning and
asks `continue melding <source> anyway? [y/N]`. Yes melds it as usual; no
(the default) removes any clone and fails with `IncompatibleVersion`, which
names both versions, `mind evolve`, and `--yes`. `meld --yes` skips the
question and melds with the warning, and the answer carries to the sources
that meld walks (`[discover].sources`, a marketplace's entries). With no
terminal to ask on, or under `--json` (LIFE-45), and no `--yes`, the meld
fails as if declined, and so do
the melds that `curate`, `sync`, and a policy auto-meld run on their own.
The check reads the `mind.toml` at the resolved pin.
- `DSC-117` When `mind.toml` fails to parse and a lenient read finds a
well-formed `[source].min-mind-version` the running binary does not meet,
the error is `MindTomlTooNew`: it names the file, both versions, and the
parse error, and points at `mind evolve`, since the likely cause is a key
added after this release. A parse failure in a file whose declared version
is met, or that declares none, stays the plain `Toml` error.