tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198630 Definiciones

feat(nouser): Phase A — mecanismo determinista de Mónadas

Primer trozo de Nouser/Kairos: explorador de Mónadas como agrupaciones
semánticas sobre el filesystem, sin tocar IA todavía. Cubre el 90% de
los casos con heurísticas puras.

Crates nuevos:

crates/modules/nouser/card:
- MonadManifest: la Tarjeta de Presentación de una Mónada. Espejo
  conceptual de brahman::Card pero para datos: id (Ulid), label,
  summary, centroid (vacío en Phase A), keywords, cardinality, entropy
  [0,1], dominant_lens (Grid|Code|Gallery|Database|Markdown|Tree),
  pins, members, timestamps, extensions (forward-compat).
- Diferencia explícita en docs: brahman::Card describe entidades
  runtime con payload/soma/supervision; MonadManifest describe una
  agrupación de datos sin proceso atrás.
- Validación: schema_version, label no vacío, entropy en rango,
  cardinality consistente con members.len().
- 6 tests (validación + JSON roundtrip).

crates/modules/nouser/core:
- scanner::scan_directory: walkdir → Vec<FileEntry> con metadatos.
  Skipea hidden por default; configurable max_depth y follow_links.
- cluster::by_directory: agrupa archivos por parent dir, mínimo 3
  para promover a Mónada (configurable). Computa keywords (top-N
  extensiones por freq + alfabético), elige Lens dominante por
  extensión más frecuente, entropía de Shannon normalizada.
- db::MonadDb: store en memoria con índices BTreeMap.
  resolve_members filtra IDs huérfanos.
- bin nouser con subcomandos scan, show, json. Env var
  NOUSER_MIN_FILES para el threshold.
- 13 tests (4 scanner + 6 cluster + 3 db).

Demo end-to-end:

  $ nouser scan crates
  scan: 255 archivos en crates, 19 mónadas (min_files=3)
    [01KR4C13] src       card=12  ent=0.00  lens=Code  keywords: rs
    [01KR4C13] tests     card=14  ent=0.00  lens=Code  keywords: rs
    [01KR4C13] fixtures  card=5   ent=0.00  lens=Grid  keywords: rhai

Pendientes (anotados en CHANGELOG, no urgentes):
- Phase B: bin nouser daemon que sidecarea a brahman-init.
- Phase C: pseudo-embeddings de metadatos + atracción por centroide.
- Phase D: módulo nouser-nous para el LLM real, swappable por
  priority_contexts (mock-nous en test, real-nous en prod).
- Polish: labels con 2-3 componentes del path.

cargo check --workspace: 0 errores, 0 warnings.
Tests acumulados: 58.

CHANGELOG.md actualizado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

7bdc26e61a68bc0a878122ce6659f0020a56af4e · por Sergio · (hace 5 meses) · padre bbb9a9d2f · ver el árbol en este commit

Delta semántico · 75

crates/modules/nouser/card/src/lib.rs

crates/modules/nouser/core/src/bin/nouser.rs

crates/modules/nouser/core/src/db.rs

crates/modules/nouser/core/src/lib.rs

crates/modules/nouser/core/src/scanner.rs

11 archivos · +1226 −0

diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd4189e..cd16c20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,74 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-08
 
+### feat(nouser): Phase A — mecanismo determinista de Mónadas
+Primer trozo del módulo Nouser (Kairos): explorador de Mónadas como
+"imanes semánticos" sobre el filesystem. Phase A cubre el 90% de los
+casos sin tocar IA — sólo metadatos y heurísticas.
+
+Crates nuevos:
+
+- `crates/modules/nouser/card`: `MonadManifest` (la Tarjeta de
+  Presentación de una Mónada — espejo conceptual de `brahman::Card`
+  pero para datos, no para procesos runtime). Campos: id (Ulid),
+  label, summary, centroid (vacío en Phase A), keywords, cardinality,
+  entropy [0,1], dominant_lens, pins, members, timestamps,
+  extensions (forward-compat). 6 tests de validación + JSON roundtrip.
+- `crates/modules/nouser/core`: pipeline determinista.
+  - `scanner`: walkdir → `Vec<FileEntry>` con metadatos (path, size,
+    mtime, extension). Skipea hidden por default. Configurable max
+    depth y follow_links.
+  - `cluster::by_directory`: agrupa por parent dir, mínimo 3 archivos
+    para promover a Mónada (configurable). Calcula keywords (top-N
+    extensiones por frecuencia + alfabético), elige `Lens` dominante
+    (Code/Gallery/Markdown/Database/Grid) según extensión más
+    frecuente, computa entropía de Shannon normalizada [0,1].
+  - `db`: `MonadDb` en memoria con índices BTreeMap files/monads y
+    `resolve_members(monad_id)` que filtra IDs huérfanos. Phase B
+    traerá persistencia.
+  - bin `nouser`: subcomandos `scan <dir>`, `show <dir> <prefix>`,
+    `json <dir>`. Env var `NOUSER_MIN_FILES` para tunear el threshold.
+  - 13 tests (4 scanner + 6 cluster + 3 db).
+
+Demo end-to-end:
+
+  $ nouser scan crates
+  scan: 255 archivos en crates, 19 mónadas (min_files=3)
+    [01KR4C13]  src                             card=12  ent=0.00  lens=Code
+             keywords: rs
+    [01KR4C13]  tests                           card=14  ent=0.00  lens=Code
+             keywords: rs
+    [01KR4C13]  fixtures                        card=5   ent=0.00  lens=Grid
+             keywords: rhai
+    ...
+
+  $ nouser show crates 01KR4C
+  Monad 01KR4C1370DVF6NMTW6SECNXAF
+    label:       src
+    summary:     4 archivos en crates/modules/nouser/core/src (ext: rs)
+    cardinality: 4
+    entropy:     0.0000
+    lens:        Code
+    members (4):
+            4132 bytes  crates/modules/nouser/core/src/db.rs
+            ...
+
+Pendientes para próximas fases (anotados, no urgentes):
+- **Phase B**: bin `nouser daemon` que sidecarea a brahman-init
+  declarando flows (`scan-request:json` → `monad-update:json`).
+- **Phase C**: pseudo-embeddings deterministas (hash de path/ext/size
+  a 32-d) + atracción por centroide via cosine similarity. Implementa
+  el "imán" sin LLM.
+- **Phase D**: módulo `nouser-nous` aparte para el LLM real
+  (Llama/ONNX). En `priority_contexts.test` el Init pinea a
+  `mock-nous` (embeddings determinísticos); en `prod` a `real-nous`.
+- **Polish**: labels de Mónada incluir 2-3 componentes del path para
+  desambiguar `src/` repetidos en monorepo.
+
+Workspace: 0 errores, 0 warnings. Tests acumulados: 58
+(card 11, broker 15, handshake codec+transport 2 + integ 7,
+card-wit 4, admin 0, nouser-card 6, nouser-core 13).
+
 ### feat(broker): priority contexts — biases per-contexto operativo
 - `brahman-card::ContextBias { pin_to: Option<String>, priority_offset: i8 }`
   declara un override per-contexto.
diff --git a/Cargo.lock b/Cargo.lock
index 066f436..34ded5b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6045,6 +6045,29 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "nouser-card"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "ulid",
+]
+
+[[package]]
+name = "nouser-core"
+version = "0.1.0"
+dependencies = [
+ "nouser-card",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror 2.0.18",
+ "ulid",
+ "walkdir",
+]
+
 [[package]]
 name = "ntapi"
 version = "0.4.3"
diff --git a/Cargo.toml b/Cargo.toml
index a894516..3246b48 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -64,6 +64,12 @@ members = [
     # ============================================================
     "crates/modules/nakui/core",
 
+    # ============================================================
+    # modules/nouser/ — explorador de Mónadas (nuevo)
+    # ============================================================
+    "crates/modules/nouser/card",
+    "crates/modules/nouser/core",
+
     # ============================================================
     # apps/ — apps que consumen el protocolo (yahweh modules+shell)
     # ============================================================
diff --git a/crates/modules/nouser/card/Cargo.toml b/crates/modules/nouser/card/Cargo.toml
new file mode 100644
index 0000000..4409b30
--- /dev/null
+++ b/crates/modules/nouser/card/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "nouser-card"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Nouser — manifiesto de Mónada (agrupación semántica de archivos). Espejo de brahman-card pero para datos."
+
+[dependencies]
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
diff --git a/crates/modules/nouser/card/src/lib.rs b/crates/modules/nouser/card/src/lib.rs
new file mode 100644
index 0000000..1a55a6d
--- /dev/null
+++ b/crates/modules/nouser/card/src/lib.rs
@@ -0,0 +1,334 @@
+//! `nouser-card` — manifiesto de Mónada.
+//!
+//! Una **Mónada** es una agrupación semántica de archivos: el archivo
+//! físico no se mueve, pero su pertenencia se modela por un objeto
+//! ([`MonadManifest`]) con identidad propia, métricas y un "lente" de
+//! visualización. La idea hereda el espíritu de la Tarjeta de
+//! Presentación de Brahman (`brahman-card::Card`): un manifiesto
+//! tipado, validado y serializable que define qué es la entidad y
+//! cómo el sistema debe interactuar con ella.
+//!
+//! Diferencia con `brahman-card::Card`:
+//!
+//! | brahman::Card                       | nouser::MonadManifest         |
+//! |-------------------------------------|-------------------------------|
+//! | Describe una **entidad runtime**    | Describe una **agrupación**   |
+//! | Tiene `payload`/`soma`/`supervision`| No tiene proceso detrás       |
+//! | Vive durante una sesión             | Vive en una DB persistente    |
+//! | Fluye por handshake/postcard        | Fluye por queries del backend |
+//!
+//! Este crate sólo define los tipos. La lógica de scan, cluster,
+//! attraction vive en `nouser-core`.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+use ulid::Ulid;
+
+// Re-export para consumidores
+pub use ::ulid;
+
+/// Versión del esquema del manifiesto. Bump al cambiar el schema.
+pub const MONAD_SCHEMA_VERSION: u16 = 1;
+
+/// Identificador opaco de un archivo registrado en la DB.
+pub type FileId = Ulid;
+
+/// Identificador opaco de una Mónada.
+pub type MonadId = Ulid;
+
+// =====================================================================
+// FileEntry — el archivo como dato indexado
+// =====================================================================
+
+/// Registro físico de un archivo en la DB. Es la unidad atómica que
+/// pertenece a (potencialmente varias) Mónadas.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FileEntry {
+    pub id: FileId,
+    pub path: PathBuf,
+    /// Hash de contenido (blake3) — sólo se computa si el archivo es
+    /// chico o el usuario lo pidió. `None` por default en Phase 0.
+    #[serde(default)]
+    pub content_hash: Option<[u8; 32]>,
+    /// Tamaño en bytes.
+    pub size: u64,
+    /// `mtime` como ms desde UNIX_EPOCH.
+    pub mtime_ms: u64,
+    /// Extensión normalizada en lowercase, sin punto. `None` si no tiene.
+    #[serde(default)]
+    pub extension: Option<String>,
+}
+
+// =====================================================================
+// Lens — la "vista" preferida de una Mónada
+// =====================================================================
+
+/// Lente de visualización dominante. La UI (yahweh) elige cómo renderizar
+/// los miembros de una Mónada según este hint.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Lens {
+    /// Grid genérico: thumbnail + nombre + meta.
+    #[default]
+    Grid,
+    /// Editor de código con highlighting (rs, py, ts, ...).
+    Code,
+    /// Galería de imágenes (png, jpg, svg, ...).
+    Gallery,
+    /// Vista tabular (csv, sqlite, ...).
+    Database,
+    /// Texto renderizado (md, rst, txt).
+    Markdown,
+    /// Árbol jerárquico (cuando la Mónada es estructural).
+    Tree,
+}
+
+// =====================================================================
+// MonadManifest — la Tarjeta de Presentación de la Mónada
+// =====================================================================
+
+/// Manifiesto de una Mónada. Equivalente conceptual a la Tarjeta de
+/// Presentación de Brahman, pero para una agrupación de datos.
+///
+/// Se serializa a JSON/TOML para persistencia y debugging; es el
+/// "ADN" que la UI lee para saber cómo presentar la Mónada sin tocar
+/// el disco.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MonadManifest {
+    /// Versión del esquema. Bump = romper compatibilidad de DB.
+    pub schema_version: u16,
+
+    /// Identificador opaco. ULID — orderable por tiempo de creación.
+    pub id: MonadId,
+
+    /// Mónada de la que ésta fue derivada (split, merge), si aplica.
+    #[serde(default)]
+    pub lineage: Option<MonadId>,
+
+    /// Nombre humano corto (1-4 palabras, generado por reglas o por Nous).
+    pub label: String,
+
+    /// Resumen de propósito (1-2 oraciones). Generado por Nous cuando
+    /// la masa de la Mónada justifica la consulta.
+    #[serde(default)]
+    pub summary: String,
+
+    /// Centroide vectorial (embedding promedio de los miembros). Vacío
+    /// en Phase 0 (sin embeddings); se llena cuando entran las
+    /// pseudo-embeddings o el modelo real.
+    #[serde(default)]
+    pub centroid: Vec<f32>,
+
+    /// Tokens dominantes: extensiones, palabras clave, etc.
+    /// 5-10 elementos típicamente.
+    #[serde(default)]
+    pub keywords: Vec<String>,
+
+    /// Cantidad de miembros (== `members.len()`). Cacheado para evitar
+    /// el cost de leer la lista cada vez.
+    pub cardinality: u32,
+
+    /// Métrica de dispersión interna [0.0, 1.0]:
+    /// - 0.0: todos los miembros son muy similares (Mónada coherente).
+    /// - 1.0: miembros muy heterogéneos (sugerencia: bifurcar).
+    ///
+    /// Calculada como entropía de Shannon normalizada sobre las
+    /// extensiones de los miembros.
+    #[serde(default)]
+    pub entropy: f32,
+
+    /// Lente preferido para visualización en la UI.
+    #[serde(default)]
+    pub dominant_lens: Lens,
+
+    /// Archivos anclados manualmente: NO se mueven en re-clustering
+    /// automático. El usuario "fija" estos miembros.
+    #[serde(default)]
+    pub pins: BTreeSet<FileId>,
+
+    /// IDs de archivos miembros (incluye pins).
+    pub members: BTreeSet<FileId>,
+
+    /// Unix ms de creación de la Mónada.
+    pub created_at_ms: u64,
+
+    /// Unix ms de la última actualización (re-cluster, re-name, ...).
+    pub updated_at_ms: u64,
+
+    /// Forward-compat: campos JSON desconocidos preservados.
+    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+    pub extensions: BTreeMap<String, serde_json::Value>,
+}
+
+// =====================================================================
+// Errores y validación
+// =====================================================================
+
+#[derive(Debug, Error)]
+pub enum MonadError {
+    #[error("schema mismatch: got {got}, expected {expected}")]
+    SchemaMismatch { got: u16, expected: u16 },
+    #[error("label vacío")]
+    EmptyLabel,
+    #[error("label demasiado largo: {0} bytes (max 256)")]
+    LabelTooLong(usize),
+    #[error("entropía fuera de [0,1]: {0}")]
+    InvalidEntropy(f32),
+    #[error("Monad sin miembros y sin pins")]
+    Empty,
+    #[error("cardinalidad declarada {declared} ≠ members.len() {actual}")]
+    CardinalityMismatch { declared: u32, actual: u32 },
+    #[error("JSON inválido: {0}")]
+    Json(#[from] serde_json::Error),
+}
+
+impl MonadManifest {
+    /// Constructor con defaults razonables. `id` y timestamps se
+    /// generan; resto vacío.
+    pub fn new(label: impl Into<String>) -> Self {
+        let now_ms = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map(|d| d.as_millis() as u64)
+            .unwrap_or(0);
+        Self {
+            schema_version: MONAD_SCHEMA_VERSION,
+            id: Ulid::new(),
+            lineage: None,
+            label: label.into(),
+            summary: String::new(),
+            centroid: Vec::new(),
+            keywords: Vec::new(),
+            cardinality: 0,
+            entropy: 0.0,
+            dominant_lens: Lens::default(),
+            pins: BTreeSet::new(),
+            members: BTreeSet::new(),
+            created_at_ms: now_ms,
+            updated_at_ms: now_ms,
+            extensions: BTreeMap::new(),
+        }
+    }
+
+    /// Validación semántica.
+    pub fn validate(&self) -> Result<(), MonadError> {
+        if self.schema_version != MONAD_SCHEMA_VERSION {
+            return Err(MonadError::SchemaMismatch {
+                got: self.schema_version,
+                expected: MONAD_SCHEMA_VERSION,
+            });
+        }
+        if self.label.trim().is_empty() {
+            return Err(MonadError::EmptyLabel);
+        }
+        if self.label.len() > 256 {
+            return Err(MonadError::LabelTooLong(self.label.len()));
+        }
+        if !(0.0..=1.0).contains(&self.entropy) {
+            return Err(MonadError::InvalidEntropy(self.entropy));
+        }
+        if self.members.is_empty() && self.pins.is_empty() {
+            return Err(MonadError::Empty);
+        }
+        let actual = self.members.len() as u32;
+        if self.cardinality != actual {
+            return Err(MonadError::CardinalityMismatch {
+                declared: self.cardinality,
+                actual,
+            });
+        }
+        Ok(())
+    }
+
+    /// Serializa a JSON pretty.
+    pub fn to_json_pretty(&self) -> Result<String, MonadError> {
+        Ok(serde_json::to_string_pretty(self)?)
+    }
+
+    /// Deserializa desde JSON y valida.
+    pub fn from_json(src: &str) -> Result<Self, MonadError> {
+        let m: Self = serde_json::from_str(src)?;
+        m.validate()?;
+        Ok(m)
+    }
+
+    /// Recalcula `cardinality` y `updated_at_ms` desde `members`.
+    /// Usar tras mutaciones del set de miembros.
+    pub fn touch(&mut self) {
+        self.cardinality = self.members.len() as u32;
+        self.updated_at_ms = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map(|d| d.as_millis() as u64)
+            .unwrap_or(0);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn validates_minimal() {
+        let mut m = MonadManifest::new("test");
+        m.members.insert(Ulid::new());
+        m.touch();
+        m.validate().expect("debe validar");
+    }
+
+    #[test]
+    fn empty_label_rejected() {
+        let mut m = MonadManifest::new("x");
+        m.label = String::new();
+        m.members.insert(Ulid::new());
+        m.touch();
+        assert!(matches!(m.validate(), Err(MonadError::EmptyLabel)));
+    }
+
+    #[test]
+    fn entropy_out_of_range_rejected() {
+        let mut m = MonadManifest::new("x");
+        m.members.insert(Ulid::new());
+        m.entropy = 1.5;
+        m.touch();
+        assert!(matches!(m.validate(), Err(MonadError::InvalidEntropy(_))));
+    }
+
+    #[test]
+    fn empty_members_rejected() {
+        let m = MonadManifest::new("x");
+        assert!(matches!(m.validate(), Err(MonadError::Empty)));
+    }
+
+    #[test]
+    fn cardinality_mismatch_caught() {
+        let mut m = MonadManifest::new("x");
+        m.members.insert(Ulid::new());
+        // No llamamos touch — cardinality queda en 0 con 1 miembro.
+        assert!(matches!(
+            m.validate(),
+            Err(MonadError::CardinalityMismatch { .. })
+        ));
+    }
+
+    #[test]
+    fn json_roundtrip() {
+        let mut m = MonadManifest::new("test-monad");
+        m.members.insert(Ulid::new());
+        m.members.insert(Ulid::new());
+        m.keywords = vec!["rs".into(), "toml".into()];
+        m.summary = "test summary".into();
+        m.dominant_lens = Lens::Code;
+        m.touch();
+        let s = m.to_json_pretty().unwrap();
+        let m2 = MonadManifest::from_json(&s).unwrap();
+        assert_eq!(m2.label, m.label);
+        assert_eq!(m2.cardinality, 2);
+        assert_eq!(m2.dominant_lens, Lens::Code);
+        assert_eq!(m2.keywords, m.keywords);
+    }
+}
diff --git a/crates/modules/nouser/core/Cargo.toml b/crates/modules/nouser/core/Cargo.toml
new file mode 100644
index 0000000..023d764
--- /dev/null
+++ b/crates/modules/nouser/core/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "nouser-core"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Nouser — explorador de Mónadas: scanner, clustering determinista, DB en memoria."
+
+[dependencies]
+nouser-card = { path = "../card" }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
+walkdir = "2"
+
+[dev-dependencies]
+tempfile = { workspace = true }
+
+[[bin]]
+name = "nouser"
+path = "src/bin/nouser.rs"
diff --git a/crates/modules/nouser/core/src/bin/nouser.rs b/crates/modules/nouser/core/src/bin/nouser.rs
new file mode 100644
index 0000000..03fa85d
--- /dev/null
+++ b/crates/modules/nouser/core/src/bin/nouser.rs
@@ -0,0 +1,157 @@
+//! `nouser` CLI — explorador de Mónadas.
+//!
+//! Subcomandos:
+//!
+//! - `scan <dir>`        recorre `dir` y muestra las Mónadas detectadas.
+//! - `show <dir> <id?>`  scan + detalles de la Mónada con prefijo de ID.
+//! - `json <dir>`        scan + dump JSON con los manifests.
+//!
+//! Phase A: in-memory, sin persistencia, sin brahman sidecar. La
+//! sesión termina y todo se descarta. Phase B agrega persistencia y
+//! presencia ante el Init.
+
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use nouser_core::{
+    cluster, db,
+    scanner::{self, ScanConfig},
+};
+
+fn main() -> ExitCode {
+    let args: Vec<String> = std::env::args().collect();
+    let prog = args.first().cloned().unwrap_or_else(|| "nouser".into());
+    let sub = match args.get(1).map(String::as_str) {
+        Some(s) => s,
+        None => {
+            print_usage(&prog);
+            return ExitCode::from(2);
+        }
+    };
+    let rest = &args[2..];
+
+    let result = match sub {
+        "scan" => cmd_scan(rest),
+        "show" => cmd_show(rest),
+        "json" => cmd_json(rest),
+        "--help" | "-h" | "help" => {
+            print_usage(&prog);
+            return ExitCode::SUCCESS;
+        }
+        other => {
+            eprintln!("nouser: comando desconocido '{other}'");
+            print_usage(&prog);
+            return ExitCode::from(2);
+        }
+    };
+
+    match result {
+        Ok(()) => ExitCode::SUCCESS,
+        Err(e) => {
+            eprintln!("nouser: {e}");
+            ExitCode::from(1)
+        }
+    }
+}
+
+fn print_usage(prog: &str) {
+    eprintln!("uso: {prog} <comando> [args]");
+    eprintln!();
+    eprintln!("comandos:");
+    eprintln!("  scan <dir>           recorre un directorio y lista las Mónadas detectadas");
+    eprintln!("  show <dir> <prefix>  scan + detalle de la Mónada cuyo ID empieza con <prefix>");
+    eprintln!("  json <dir>           scan + dump JSON de todos los manifests");
+    eprintln!();
+    eprintln!("env:");
+    eprintln!("  NOUSER_MIN_FILES     mínimo de archivos por Mónada (default: 3)");
+}
+
+type Cmd = Result<(), Box<dyn std::error::Error>>;
+
+fn min_files() -> usize {
+    std::env::var("NOUSER_MIN_FILES")
+        .ok()
+        .and_then(|s| s.parse().ok())
+        .unwrap_or(cluster::DEFAULT_MIN_FILES_PER_MONAD)
+}
+
+fn require_dir(args: &[String]) -> Result<PathBuf, Box<dyn std::error::Error>> {
+    let dir = args.first().ok_or("falta argumento <dir>")?;
+    Ok(PathBuf::from(dir))
+}
+
+fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box<dyn std::error::Error>> {
+    let files = scanner::scan_directory(dir, &ScanConfig::default())?;
+    let n_files = files.len();
+    let monads = cluster::by_directory(&files, min_files());
+    let mut db = db::MonadDb::new();
+    db.ingest_files(files);
+    db.replace_monads(monads);
+    Ok((db, n_files))
+}
+
+fn cmd_scan(args: &[String]) -> Cmd {
+    let dir = require_dir(args)?;
+    let (db, n_files) = run_scan(&dir)?;
+
+    println!(
+        "scan: {} archivos en {}, {} mónadas (min_files={})",
+        n_files,
+        dir.display(),
+        db.monad_count(),
+        min_files()
+    );
+    if db.monad_count() == 0 {
+        println!("  (ninguna Mónada — bajá NOUSER_MIN_FILES o apuntá a un dir con más archivos)");
+        return Ok(());
+    }
+    println!();
+    for m in db.monads() {
+        let id_short = format!("{}", m.id);
+        let id_short = &id_short[..8];
+        println!(
+            "  [{}]  {:30}  card={}  ent={:.2}  lens={:?}",
+            id_short, m.label, m.cardinality, m.entropy, m.dominant_lens,
+        );
+        if !m.keywords.is_empty() {
+            println!("           keywords: {}", m.keywords.join(", "));
+        }
+    }
+    Ok(())
+}
+
+fn cmd_show(args: &[String]) -> Cmd {
+    let dir = require_dir(args)?;
+    let prefix = args.get(1).ok_or("falta argumento <prefix>")?;
+    let (db, _) = run_scan(&dir)?;
+
+    let m = db
+        .monads()
+        .find(|m| m.id.to_string().starts_with(prefix))
+        .ok_or_else(|| format!("ninguna Mónada con prefijo '{prefix}'"))?;
+
+    println!("Monad {}", m.id);
+    println!("  label:       {}", m.label);
+    println!("  summary:     {}", m.summary);
+    println!("  cardinality: {}", m.cardinality);
+    println!("  entropy:     {:.4}", m.entropy);
+    println!("  lens:        {:?}", m.dominant_lens);
+    println!("  keywords:    {}", m.keywords.join(", "));
+    println!("  members ({}):", m.members.len());
+    for f in db.resolve_members(m.id) {
+        println!(
+            "    {:>10} bytes  {}",
+            f.size,
+            f.path.display()
+        );
+    }
+    Ok(())
+}
+
+fn cmd_json(args: &[String]) -> Cmd {
+    let dir = require_dir(args)?;
+    let (db, _) = run_scan(&dir)?;
+    let manifests: Vec<_> = db.monads().cloned().collect();
+    println!("{}", serde_json::to_string_pretty(&manifests)?);
+    Ok(())
+}
diff --git a/crates/modules/nouser/core/src/cluster.rs b/crates/modules/nouser/core/src/cluster.rs
new file mode 100644
index 0000000..b8deac0
--- /dev/null
+++ b/crates/modules/nouser/core/src/cluster.rs
@@ -0,0 +1,240 @@
+//! Clustering determinista (Phase A).
+//!
+//! Estrategia: agrupar por **directorio padre** + ranking por
+//! **extensión dominante**. No hay LLM ni embeddings — sólo metadatos.
+//! Esta capa cubre el 90% de los casos prácticos:
+//!
+//! - Un proyecto Rust en `~/dev/foo/src/` → Mónada coherente (.rs).
+//! - Un dump de fotos en `~/Pictures/2024/` → Mónada con lente Gallery.
+//! - Notas en `~/notes/` → Mónada con lente Markdown.
+//!
+//! Los casos donde esta heurística falla (archivos relacionados pero
+//! dispersos en el FS) son el dominio de los embeddings (Phase C) y
+//! del clustering por Nous (Phase D).
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use nouser_card::{FileEntry, Lens, MonadManifest};
+
+/// Mínimo de archivos para que un directorio sea promovido a Mónada.
+/// Por debajo de eso, los archivos quedan "huérfanos" (no asignados).
+pub const DEFAULT_MIN_FILES_PER_MONAD: usize = 3;
+
+/// Agrupa archivos en Mónadas por directorio padre.
+///
+/// Devuelve un `Vec<MonadManifest>` ordenado por path. Archivos en
+/// directorios con menos de `min_files` no producen Mónada.
+pub fn by_directory(files: &[FileEntry], min_files: usize) -> Vec<MonadManifest> {
+    let mut by_parent: BTreeMap<PathBuf, Vec<&FileEntry>> = BTreeMap::new();
+    for f in files {
+        if let Some(parent) = f.path.parent() {
+            by_parent.entry(parent.to_path_buf()).or_default().push(f);
+        }
+    }
+
+    let mut out = Vec::new();
+    for (parent, group) in by_parent {
+        if group.len() < min_files {
+            continue;
+        }
+        out.push(build_monad(&parent, &group));
+    }
+    out
+}
+
+fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest {
+    let label = parent
+        .file_name()
+        .and_then(|s| s.to_str())
+        .unwrap_or("unnamed")
+        .to_string();
+
+    let keywords = top_extensions(group, 5);
+    let lens = pick_lens(group);
+    let entropy = shannon_entropy_normalized(group);
+
+    let summary = build_summary(parent, group, &keywords);
+
+    let mut m = MonadManifest::new(label);
+    m.summary = summary;
+    m.keywords = keywords;
+    m.dominant_lens = lens;
+    m.entropy = entropy;
+    m.members = group.iter().map(|f| f.id).collect();
+    m.touch();
+    m
+}
+
+fn build_summary(parent: &std::path::Path, group: &[&FileEntry], keywords: &[String]) -> String {
+    let path_str = parent.display();
+    let n = group.len();
+    let exts = if keywords.is_empty() {
+        "(sin extensiones)".to_string()
+    } else {
+        keywords.join(", ")
+    };
+    format!("{n} archivos en {path_str} (ext: {exts})")
+}
+
+/// Top-N extensiones por frecuencia, descendente. Empate por orden alfabético.
+fn top_extensions(files: &[&FileEntry], n: usize) -> Vec<String> {
+    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
+    for f in files {
+        if let Some(ext) = &f.extension {
+            *counts.entry(ext.clone()).or_default() += 1;
+        }
+    }
+    let mut sorted: Vec<_> = counts.into_iter().collect();
+    sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
+    sorted.into_iter().take(n).map(|(k, _)| k).collect()
+}
+
+/// Elige el lente dominante según la extensión más frecuente.
+fn pick_lens(files: &[&FileEntry]) -> Lens {
+    let dominant = top_extensions(files, 1).into_iter().next();
+    match dominant.as_deref() {
+        Some("rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "kt" | "c" | "cpp"
+        | "cc" | "h" | "hpp" | "rb" | "swift" | "zig") => Lens::Code,
+        Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "tiff" | "heic") => {
+            Lens::Gallery
+        }
+        Some("md" | "markdown" | "rst" | "txt" | "org" | "tex") => Lens::Markdown,
+        Some("db" | "sqlite" | "sqlite3" | "csv" | "tsv" | "parquet") => Lens::Database,
+        _ => Lens::Grid,
+    }
+}
+
+/// Entropía de Shannon normalizada sobre la distribución de extensiones.
+/// `0.0` = todos los archivos comparten extensión. `1.0` = uniformly
+/// distributed entre `n` extensiones (máx información).
+fn shannon_entropy_normalized(files: &[&FileEntry]) -> f32 {
+    let total = files.len() as f32;
+    if total <= 1.0 {
+        return 0.0;
+    }
+    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
+    for f in files {
+        let ext = f.extension.as_deref().unwrap_or("(none)");
+        *counts.entry(ext.to_string()).or_default() += 1;
+    }
+    let entropy: f32 = counts
+        .values()
+        .map(|&c| {
+            let p = c as f32 / total;
+            -p * p.log2()
+        })
+        .sum();
+    let max_entropy = (counts.len() as f32).log2();
+    if max_entropy <= 0.0 {
+        0.0
+    } else {
+        (entropy / max_entropy).clamp(0.0, 1.0)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use nouser_card::FileId;
+    use std::path::PathBuf;
+    use ulid::Ulid;
+
+    fn mkfile(path: &str, ext: Option<&str>) -> FileEntry {
+        FileEntry {
+            id: FileId::from(Ulid::new()),
+            path: PathBuf::from(path),
+            content_hash: None,
+            size: 100,
+            mtime_ms: 0,
+            extension: ext.map(String::from),
+        }
+    }
+
+    #[test]
+    fn groups_by_parent_directory() {
+        let files = vec![
+            mkfile("/proj/src/a.rs", Some("rs")),
+            mkfile("/proj/src/b.rs", Some("rs")),
+            mkfile("/proj/src/c.rs", Some("rs")),
+            mkfile("/proj/docs/readme.md", Some("md")),
+            mkfile("/proj/docs/guide.md", Some("md")),
+            mkfile("/proj/docs/notes.md", Some("md")),
+        ];
+        let monads = by_directory(&files, 3);
+        assert_eq!(monads.len(), 2);
+        let labels: std::collections::BTreeSet<_> = monads.iter().map(|m| &m.label).collect();
+        assert!(labels.iter().any(|l| l.as_str() == "src"));
+        assert!(labels.iter().any(|l| l.as_str() == "docs"));
+    }
+
+    #[test]
+    fn small_groups_not_promoted() {
+        let files = vec![
+            mkfile("/proj/single.txt", Some("txt")),
+            mkfile("/proj/sub/a.txt", Some("txt")),
+            mkfile("/proj/sub/b.txt", Some("txt")),
+            mkfile("/proj/sub/c.txt", Some("txt")),
+        ];
+        // min=3 → /proj/single solo no se promueve, /proj/sub sí.
+        let monads = by_directory(&files, 3);
+        assert_eq!(monads.len(), 1);
+        assert_eq!(monads[0].label, "sub");
+    }
+
+    #[test]
+    fn lens_picked_by_dominant_extension() {
+        let files = vec![
+            mkfile("/x/a.rs", Some("rs")),
+            mkfile("/x/b.rs", Some("rs")),
+            mkfile("/x/c.rs", Some("rs")),
+        ];
+        let monads = by_directory(&files, 3);
+        assert_eq!(monads[0].dominant_lens, Lens::Code);
+
+        let files = vec![
+            mkfile("/y/1.png", Some("png")),
+            mkfile("/y/2.png", Some("png")),
+            mkfile("/y/3.png", Some("png")),
+        ];
+        let monads = by_directory(&files, 3);
+        assert_eq!(monads[0].dominant_lens, Lens::Gallery);
+    }
+
+    #[test]
+    fn entropy_zero_for_homogeneous() {
+        let files = vec![
+            mkfile("/x/a.rs", Some("rs")),
+            mkfile("/x/b.rs", Some("rs")),
+            mkfile("/x/c.rs", Some("rs")),
+        ];
+        let monads = by_directory(&files, 3);
+        assert_eq!(monads[0].entropy, 0.0);
+    }
+
+    #[test]
+    fn entropy_high_for_diverse() {
+        let files = vec![
+            mkfile("/x/a.rs", Some("rs")),
+            mkfile("/x/b.md", Some("md")),
+            mkfile("/x/c.json", Some("json")),
+            mkfile("/x/d.png", Some("png")),
+        ];
+        let monads = by_directory(&files, 3);
+        // 4 extensiones distintas, distribución uniforme → entropy ≈ 1.0
+        assert!(monads[0].entropy > 0.9, "got {}", monads[0].entropy);
+    }
+
+    #[test]
+    fn top_extensions_orders_by_freq_then_alpha() {
+        let files = vec![
+            mkfile("/x/a.rs", Some("rs")),
+            mkfile("/x/b.rs", Some("rs")),
+            mkfile("/x/c.md", Some("md")),
+            mkfile("/x/d.py", Some("py")),
+        ];
+        let refs: Vec<&FileEntry> = files.iter().collect();
+        let top = top_extensions(&refs, 3);
+        assert_eq!(top, vec!["rs", "md", "py"]);
+    }
+}
diff --git a/crates/modules/nouser/core/src/db.rs b/crates/modules/nouser/core/src/db.rs
new file mode 100644
index 0000000..1d76023
--- /dev/null
+++ b/crates/modules/nouser/core/src/db.rs
@@ -0,0 +1,149 @@
+//! DB en memoria de Mónadas y archivos.
+//!
+//! Phase A: store volátil con índices `BTreeMap<Id, Manifest>` para
+//! ambos lados. Phase B traerá persistencia (sled o sqlite) y un
+//! índice `file_id → Vec<monad_id>` (membership).
+
+use std::collections::BTreeMap;
+
+use nouser_card::{FileEntry, FileId, MonadId, MonadManifest};
+
+/// Store de Mónadas + archivos. Operaciones lock-free (mut & por
+/// usuario externo). Para uso multi-thread, envolvé en `Mutex/RwLock`.
+#[derive(Debug, Default)]
+pub struct MonadDb {
+    files: BTreeMap<FileId, FileEntry>,
+    monads: BTreeMap<MonadId, MonadManifest>,
+}
+
+impl MonadDb {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    // ---- Files ----
+
+    pub fn insert_file(&mut self, file: FileEntry) -> Option<FileEntry> {
+        self.files.insert(file.id, file)
+    }
+
+    pub fn ingest_files(&mut self, files: Vec<FileEntry>) {
+        for f in files {
+            self.files.insert(f.id, f);
+        }
+    }
+
+    pub fn file(&self, id: FileId) -> Option<&FileEntry> {
+        self.files.get(&id)
+    }
+
+    pub fn files(&self) -> impl Iterator<Item = &FileEntry> + '_ {
+        self.files.values()
+    }
+
+    pub fn file_count(&self) -> usize {
+        self.files.len()
+    }
+
+    // ---- Monads ----
+
+    pub fn insert_monad(&mut self, monad: MonadManifest) -> Option<MonadManifest> {
+        self.monads.insert(monad.id, monad)
+    }
+
+    pub fn replace_monads(&mut self, monads: Vec<MonadManifest>) {
+        self.monads.clear();
+        for m in monads {
+            self.monads.insert(m.id, m);
+        }
+    }
+
+    pub fn monad(&self, id: MonadId) -> Option<&MonadManifest> {
+        self.monads.get(&id)
+    }
+
+    pub fn monads(&self) -> impl Iterator<Item = &MonadManifest> + '_ {
+        self.monads.values()
+    }
+
+    pub fn monad_count(&self) -> usize {
+        self.monads.len()
+    }
+
+    /// Resuelve los archivos miembros de una Mónada como referencias.
+    /// Skipea silenciosamente IDs que ya no estén en la tabla `files`.
+    pub fn resolve_members(&self, monad_id: MonadId) -> Vec<&FileEntry> {
+        match self.monads.get(&monad_id) {
+            Some(m) => m.members.iter().filter_map(|id| self.files.get(id)).collect(),
+            None => Vec::new(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use nouser_card::Lens;
+    use ulid::Ulid;
+
+    fn mk_file(path: &str) -> FileEntry {
+        FileEntry {
+            id: FileId::from(Ulid::new()),
+            path: std::path::PathBuf::from(path),
+            content_hash: None,
+            size: 100,
+            mtime_ms: 0,
+            extension: Some("rs".into()),
+        }
+    }
+
+    #[test]
+    fn ingest_and_lookup() {
+        let mut db = MonadDb::new();
+        let f1 = mk_file("/a/x.rs");
+        let f2 = mk_file("/a/y.rs");
+        let id1 = f1.id;
+        db.ingest_files(vec![f1, f2]);
+        assert_eq!(db.file_count(), 2);
+        assert!(db.file(id1).is_some());
+    }
+
+    #[test]
+    fn resolve_members_filters_missing() {
+        let mut db = MonadDb::new();
+        let f1 = mk_file("/x/a.rs");
+        let id1 = f1.id;
+        db.insert_file(f1);
+
+        let mut m = MonadManifest::new("test");
+        m.members.insert(id1);
+        m.members.insert(FileId::from(Ulid::new())); // miembro fantasma
+        m.dominant_lens = Lens::Code;
+        m.touch();
+
+        let mid = m.id;
+        db.insert_monad(m);
+
+        let resolved = db.resolve_members(mid);
+        // Sólo el archivo realmente presente en files.
+        assert_eq!(resolved.len(), 1);
+        assert_eq!(resolved[0].id, id1);
+    }
+
+    #[test]
+    fn replace_monads_clears_old() {
+        let mut db = MonadDb::new();
+        let mut m1 = MonadManifest::new("a");
+        m1.members.insert(FileId::from(Ulid::new()));
+        m1.touch();
+        db.insert_monad(m1);
+        assert_eq!(db.monad_count(), 1);
+
+        let mut m2 = MonadManifest::new("b");
+        m2.members.insert(FileId::from(Ulid::new()));
+        m2.touch();
+        db.replace_monads(vec![m2]);
+        assert_eq!(db.monad_count(), 1);
+        assert!(db.monads().next().unwrap().label == "b");
+    }
+}
diff --git a/crates/modules/nouser/core/src/lib.rs b/crates/modules/nouser/core/src/lib.rs
new file mode 100644
index 0000000..b713690
--- /dev/null
+++ b/crates/modules/nouser/core/src/lib.rs
@@ -0,0 +1,32 @@
+//! `nouser-core` — el explorador de Mónadas.
+//!
+//! Implementa la pipeline determinista descrita en el diseño de Kairos:
+//!
+//! 1. [`scanner`]: recorre directorios y emite [`FileEntry`] (sin tocar
+//!    contenido en Phase 0 — sólo metadatos).
+//! 2. [`cluster`]: agrupa archivos en [`MonadManifest`] usando
+//!    heurísticas (parent dir + extensión dominante). 0 LLM.
+//! 3. [`db`]: store en memoria con índices files↔monads.
+//!
+//! Pipeline:
+//! ```text
+//! scan_directory(path)
+//!     → Vec<FileEntry>
+//!         → cluster::by_directory(min_files=N)
+//!             → Vec<MonadManifest>
+//!                 → MonadDb::ingest(...)
+//! ```
+//!
+//! Lo importante: en este crate no hay IA, no hay embeddings. Es la
+//! capa determinista que cubre el 90% de los casos. Los embeddings
+//! (`Phase C`) y Nous (`Phase D`) se enchufan después como módulos
+//! separados que producen flows brahman.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+pub mod cluster;
+pub mod db;
+pub mod scanner;
+
+pub use nouser_card::*;
diff --git a/crates/modules/nouser/core/src/scanner.rs b/crates/modules/nouser/core/src/scanner.rs
new file mode 100644
index 0000000..ed4581e
--- /dev/null
+++ b/crates/modules/nouser/core/src/scanner.rs
@@ -0,0 +1,178 @@
+//! Recorrido de directorios. Sólo metadatos — no lee contenido.
+//!
+//! Usa `walkdir` (sequential). Para árboles muy grandes considerar
+//! migrar a `jwalk` (paralelo); por ahora la simplicidad gana.
+
+use std::path::{Path, PathBuf};
+use std::time::UNIX_EPOCH;
+
+use nouser_card::{FileEntry, FileId};
+use thiserror::Error;
+use ulid::Ulid;
+use walkdir::WalkDir;
+
+#[derive(Debug, Error)]
+pub enum ScanError {
+    #[error("ruta no existe: {0}")]
+    NotFound(PathBuf),
+    #[error("no se pudo leer: {0}")]
+    Walk(String),
+}
+
+/// Configuración del scan.
+#[derive(Debug, Clone)]
+pub struct ScanConfig {
+    /// Profundidad máxima (None = ilimitada).
+    pub max_depth: Option<usize>,
+    /// Sigue symlinks (default: false, evita ciclos).
+    pub follow_links: bool,
+    /// Ignora archivos ocultos (.dotfiles).
+    pub skip_hidden: bool,
+}
+
+impl Default for ScanConfig {
+    fn default() -> Self {
+        Self {
+            max_depth: None,
+            follow_links: false,
+            skip_hidden: true,
+        }
+    }
+}
+
+/// Recorre `root` y devuelve un `FileEntry` por cada archivo regular.
+/// Errores de permisos en sub-paths se ignoran silenciosamente.
+pub fn scan_directory(root: &Path, config: &ScanConfig) -> Result<Vec<FileEntry>, ScanError> {
+    if !root.exists() {
+        return Err(ScanError::NotFound(root.to_path_buf()));
+    }
+
+    let mut walker = WalkDir::new(root).follow_links(config.follow_links);
+    if let Some(d) = config.max_depth {
+        walker = walker.max_depth(d);
+    }
+
+    let mut entries = Vec::new();
+    for entry_result in walker {
+        let entry = match entry_result {
+            Ok(e) => e,
+            Err(_) => continue,
+        };
+        if !entry.file_type().is_file() {
+            continue;
+        }
+        if config.skip_hidden && is_hidden(entry.path()) {
+            continue;
+        }
+        let metadata = match entry.metadata() {
+            Ok(m) => m,
+            Err(_) => continue,
+        };
+        let mtime_ms = metadata
+            .modified()
+            .ok()
+            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
+            .map(|d| d.as_millis() as u64)
+            .unwrap_or(0);
+        let extension = entry
+            .path()
+            .extension()
+            .and_then(|s| s.to_str())
+            .map(|s| s.to_lowercase());
+
+        entries.push(FileEntry {
+            id: FileId::from(Ulid::new()),
+            path: entry.path().to_path_buf(),
+            content_hash: None,
+            size: metadata.len(),
+            mtime_ms,
+            extension,
+        });
+    }
+    Ok(entries)
+}
+
+/// `true` si alguno de los componentes del path empieza con `.`.
+/// Excluye el primer componente (root) para no descartar el directorio raíz
+/// si el usuario apuntó a un dotfile-dir explícito.
+fn is_hidden(path: &Path) -> bool {
+    path.file_name()
+        .and_then(|n| n.to_str())
+        .map(|n| n.starts_with('.'))
+        .unwrap_or(false)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::fs;
+
+    fn write(path: &Path, content: &str) {
+        if let Some(parent) = path.parent() {
+            fs::create_dir_all(parent).unwrap();
+        }
+        fs::write(path, content).unwrap();
+    }
+
+    #[test]
+    fn scans_basic_tree() {
+        let tmp = tempfile::tempdir().unwrap();
+        let root = tmp.path();
+        write(&root.join("a.rs"), "fn main(){}");
+        write(&root.join("b.rs"), "fn b(){}");
+        write(&root.join("data/x.json"), "{}");
+        write(&root.join("data/y.json"), "{}");
+
+        let files = scan_directory(root, &ScanConfig::default()).unwrap();
+        assert_eq!(files.len(), 4);
+        let exts: std::collections::BTreeSet<_> = files
+            .iter()
+            .filter_map(|f| f.extension.clone())
+            .collect();
+        assert!(exts.contains("rs"));
+        assert!(exts.contains("json"));
+    }
+
+    #[test]
+    fn skips_hidden_by_default() {
+        let tmp = tempfile::tempdir().unwrap();
+        let root = tmp.path();
+        write(&root.join("visible.txt"), "x");
+        write(&root.join(".hidden"), "x");
+
+        let files = scan_directory(root, &ScanConfig::default()).unwrap();
+        assert_eq!(files.len(), 1);
+        assert!(files[0].path.ends_with("visible.txt"));
+    }
+
+    #[test]
+    fn missing_root_errors() {
+        let p = std::path::Path::new("/nonexistent-12345-abc");
+        assert!(matches!(
+            scan_directory(p, &ScanConfig::default()),
+            Err(ScanError::NotFound(_))
+        ));
+    }
+
+    #[test]
+    fn max_depth_limits() {
+        let tmp = tempfile::tempdir().unwrap();
+        let root = tmp.path();
+        write(&root.join("top.txt"), "x");
+        write(&root.join("a/b/deep.txt"), "x");
+
+        let cfg = ScanConfig {
+            max_depth: Some(1),
+            ..Default::default()
+        };
+        let files = scan_directory(root, &cfg).unwrap();
+        // max_depth=1 incluye archivos en root pero no anidados profundos.
+        let names: Vec<_> = files
+            .iter()
+            .filter_map(|f| f.path.file_name().and_then(|s| s.to_str()))
+            .map(String::from)
+            .collect();
+        assert!(names.contains(&"top.txt".to_string()));
+        assert!(!names.contains(&"deep.txt".to_string()));
+    }
+}