diff --git a/.claude/rules/knowledge-plane.md b/.claude/rules/knowledge-plane.md index cc5af07..15b9d2e 100644 --- a/.claude/rules/knowledge-plane.md +++ b/.claude/rules/knowledge-plane.md @@ -46,7 +46,13 @@ completo (tres capas, principios invariantes, scope de cada rama G1–G4). 3. **Snapshots deterministas**: el renderer no debe usar `Date.now()` ni rutas del host. El timestamp se inyecta vía `profile.metadata.generatedAt`. -4. **Opt-in gate**: el renderer sólo emite archivos cuando +4. **Sin interpolación no escapada en templates vault**: `loadTemplate` usa `noEscape: true` + globalmente. Los templates de vault que añadan `{{answers.X}}` renderizarán sin HTML-escaping. + En G2 `config.md.hbs` no tiene interpolaciones — riesgo cero hoy. Si G3/G4 añade + interpolación de datos del profile, revisar si el campo puede contener input arbitrario + y escapar explícitamente con `{{{{raw}}}}` o sanitizar antes de inyectar. + +5. **Opt-in gate**: el renderer sólo emite archivos cuando `answers["integrations.knowledge_plane.enabled"] === true`. Con `false` (default), la función devuelve `[]`. diff --git a/HANDOFF.md b/HANDOFF.md index a5a52e2..f5bfc97 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,7 +5,7 @@ ## 1. Snapshot - Repo: `project-operating-system` (plugin `pos`). -- Rama actual: **`feat/g1-knowledge-plane-contract` ✅ PR pendiente** (Fase G entry-point real — contrato tool-agnostic, `docs/KNOWLEDGE_PLANE.md` standalone, `.claude/rules/knowledge-plane.md`, schema field `integrations.knowledge_plane.enabled: boolean default false`). Anterior: **`chore/roadmap-mark-fx-knowledge-plane-plan-done` ✅ #30** (docs-sync de `feat/fx-knowledge-plane-plan` mergeada como `cc7d2c3` #14). Siguiente: **`feat/g2-adapter-obsidian-reference`** (renderer Obsidian + esqueleto `vault/`). +- Rama actual: **`feat/g2-adapter-obsidian-reference` ✅ PR pendiente** (renderer `knowledge-plane-vault.ts` + `templates/vault/config.md.hbs` + grupo congelado `knowledgePlaneRenderers` en `index.ts`; opt-in gate — `enabled: true` → 3 archivos vault, `false`/ausente → `[]`; Obsidian Web Clipper como reference adapter en `vault/config.md`, no contrato base; naming ratificado: `config.md` no `schema.md`; 19 tests nuevos, 538 total, cobertura 93%/86% branches). Anterior: **`feat/g1-knowledge-plane-contract` ✅ PR #31** (contrato tool-agnostic, `docs/KNOWLEDGE_PLANE.md`, `.claude/rules/knowledge-plane.md`, schema field `integrations.knowledge_plane.enabled`). Siguiente: **`feat/g3-ingest-cli`** (stub CLI `pos knowledge ingest`, diferida hasta que haya caso real). - `refactor/template-policy-d5b-migration` entregó: `templates/policy.yaml.hbs` migrado al shape contractual con loader (A1 `pre_write.enforced_patterns: []` + A2 `skills_allowed` omitido + A3 `pre_compact.persist` 3 items canónicos + A4 `post_merge.skills_conditional[0].trigger` con globs genéricos conservadores) + 3 snapshots regenerados (cli-tool, nextjs-app, agent-sdk) + cleanup de overlays D4+D5 en `bin/_selftest.py` (D3+D6 mantienen overlays mínimos por diseño explícito). Contract test Python-side `bin/tests/test_template_loader_contract.py` corre los 5 accessors reales del loader sobre el output del generator real. Suite: 671 passed + 1 skipped (vs main baseline 644 + 1 skip; +27 contract tests, sin regresión). Vitest 515/515. Selftest 5/5 escenarios verdes sin overlays para D4/D5. - F4 entregó: `.claude-plugin/marketplace.json` (manifest oficial Claude Code marketplace primitive: top-level `{name, owner, plugins, metadata}` + `owner.name="javiAI"` + `plugins[0].source.{source:github, repo:javiAI/project-operating-system, ref:v0.1.0}`) + `.github/workflows/release.yml` (5 jobs: version-match → selftest + build-bundle → publish-release → mirror-marketplace condicional via `vars.POS_MARKETPLACE_REPO`) + `docs/RELEASE.md` (runbook de versionado + bundle + flujo + recovery + activación de mirror) + bump `plugin.json.version` 0.0.1→0.1.0 (single source of truth: tag git = `v${version}`; `marketplace.json.source.ref` espeja). Bundle release curated plugin-only (excluye `generator/`, `templates/`, `questionnaire/`, `tools/`). Repo público `javiAI/pos-marketplace` **diferido** — creación manual cuando se decida ir live; mirror skippea silenciosamente si `POS_MARKETPLACE_REPO` está vacío. Suite: 665 passed + 1 skipped. - F3 entregó: `bin/pos-selftest.sh` (wrapper bash mínimo) + `bin/_selftest.py` (orquestador stdlib Python) + `bin/tests/test_selftest_smoke.py` + `bin/tests/test_selftest_scenarios.py` (5 escenarios funcionales-críticos D1/D3/D4/D5/D6 sobre proyecto sintético generado real-time por `npx tsx generator/run.ts --profile cli-tool.yaml`). CI: nuevo job `selftest` (ubuntu × py 3.11) en `.github/workflows/ci.yml`. Sin Claude Code runtime, sin invocaciones reales de skills/agents. @@ -138,7 +138,8 @@ Hasta F1 el plugin reusaba subagents built-in; desde F2 los críticos son propio Fase F + drift template **cerrados** tras F4 + `refactor/template-policy-d5b-migration`. Carry-overs abiertos: -- **`feat/g1-knowledge-plane-contract`** ✅ PR pendiente. Entregó: `docs/KNOWLEDGE_PLANE.md` (contrato standalone 3 capas + principios invariantes + scope G1-G4), `.claude/rules/knowledge-plane.md` (path-scoped `templates/vault/**` + `vault/**`), `questionnaire/schema.yaml` campo `integrations.knowledge_plane.enabled` (boolean, default false), ARCHITECTURE §1.1 resumen+puntero, 2 fixtures + 2 test cases. Suite 517 vitest. Siguiente: **`feat/g2-adapter-obsidian-reference`** (renderer reference adapter, esqueleto vault/). +- **`feat/g2-adapter-obsidian-reference`** ✅ PR pendiente. Entregó: `generator/renderers/knowledge-plane-vault.ts` (opt-in gate, `[]` cuando `enabled !== true`), `templates/vault/config.md.hbs` (5 secciones, Obsidian Web Clipper como reference adapter), `knowledgePlaneRenderers` grupo congelado en `index.ts` (6ª aplicación patrón renderer-group). Decisión naming: `config.md` no `schema.md` (G1 gana). 19 tests nuevos; 538 vitest total; cobertura 93%/86% branches sobre renderer. Siguiente: **`feat/g3-ingest-cli`** (diferida — reabrir cuando haya caso real; requiere G1+G2 cerradas). +- **`feat/g1-knowledge-plane-contract`** ✅ PR #31. Entregó: `docs/KNOWLEDGE_PLANE.md`, `.claude/rules/knowledge-plane.md`, schema field `integrations.knowledge_plane.enabled`, ARCHITECTURE §1.1, 2 fixtures + 2 test cases. Suite 517 vitest. - **Activación del marketplace público**: cuando se decida crear `javiAI/pos-marketplace`, seguir el runbook de [docs/RELEASE.md § Mirror al marketplace público](docs/RELEASE.md) (3 pasos: crear repo + `gh variable set POS_MARKETPLACE_REPO` + `gh secret set POS_MARKETPLACE_TOKEN`). El próximo release abre PR automático contra el repo público. - **Skills `/pos:pr-description` + `/pos:release`**: diferidas por regla #7 CLAUDE.md (≥2 repeticiones documentadas). F4 entrega flow manual; cuando se observe el patrón ≥2 veces, extraer. - **`audit.yml` nightly**: declarado en `policy.yaml.ci_cd.workflows` desde Fase A; sin consumer activo. Reabrir cuando `npm audit` + `pip-audit` + `/pos:audit-plugin --self` justifiquen ejecución periódica. diff --git a/MASTER_PLAN.md b/MASTER_PLAN.md index 5729060..bb3403f 100644 --- a/MASTER_PLAN.md +++ b/MASTER_PLAN.md @@ -905,24 +905,28 @@ cero código de adapter o ingest. ### Rama G2 — `feat/g2-adapter-obsidian-reference` -**Scope**: **primer reference adapter** sobre Obsidian + Obsidian Web Clipper. Renderer nuevo que, cuando `integrations.knowledge_plane.enabled` está on, emite esqueleto mínimo del vault: +**Scope**: renderer nuevo que, cuando `integrations.knowledge_plane.enabled` está on, emite esqueleto mínimo del vault. Obsidian + Web Clipper documentado como **ingestor manual recomendado** (no contrato base). El knowledge plane permanece file-based/tool-agnostic — cualquier editor Markdown (Logseq, Foam, plain-text) es compatible por construcción. -- `vault/schema.md` — template inicial (estructura, convenciones, cómo añadir fuentes). +Output cuando `enabled: true`: + +- `vault/config.md` — skeleton minimal con TODOs (§ Propósito / Estructura / Convenciones raw / Convenciones wiki / Ingestor recomendado). - `vault/raw/.gitkeep` - `vault/wiki/.gitkeep` -Documenta Obsidian Web Clipper como **ingestor manual recomendado** (extensión oficial que guarda páginas web como Markdown en el vault). **Adapter de referencia, no definitivo**: el knowledge plane permanece file-based/tool-agnostic — cualquier editor Markdown (Logseq, Foam, plain-text) es compatible por construcción. +Output cuando `enabled: false` (default): `[]` — sin error, sin emisión. + +**Archivos (ratificados en Fase -1)**: -**Archivos (previstos)**: +- `templates/vault/config.md.hbs` — template Handlebars del skeleton. (Nota: MASTER_PLAN original decía `schema.md`; renombrado a `config.md` por decisión G1 — evita colisión léxica con `questionnaire/schema.yaml`. Ver `.claude/rules/knowledge-plane.md`.) +- `generator/renderers/knowledge-plane-vault.ts` + `knowledge-plane-vault.test.ts` (co-located, TDD-first, patrón C1–C5). Nombre refleja contrato tool-agnostic, no la herramienta de referencia. +- `generator/renderers/index.ts` — nuevo grupo congelado `knowledgePlaneRenderers` (patrón `renderer-group`, 6ª aplicación). +- `docs/ARCHITECTURE.md §1.1` — 2–3 frases inline referenciando el renderer entregado. -- `templates/vault/schema.md.hbs` -- `generator/renderers/knowledge-plane-obsidian.ts` + `*.test.ts` (co-located, patrón de C1–C5). -- `generator/renderers/index.ts` — registrar en nuevo grupo congelado `knowledgePlaneRenderers` (patrón `renderer-group` de [.claude/rules/generator.md](.claude/rules/generator.md)). -- `docs/ARCHITECTURE.md § 1.1` — ampliar con referencia al adapter entregado. +**Tests**: perfiles sintéticos inline en el renderer test. Sin 4º profile canónico. Sin nuevas snapshots para profiles canónicos (contador permanece en 54). -**NO incluye**: ingest automático, LLM calls, sync runtime, múltiples adapters. +**NO incluye**: ingest automático, LLM calls, sync runtime, múltiples adapters, skills, hooks, agents. -**Criterio de salida**: con `integrations.knowledge_plane.enabled: true` en el profile, `npx tsx generator/run.ts --out ` emite `vault/` esqueleto; con flag off, no se emite nada. Tests semánticos sobre paths + contenido de `vault/schema.md`. Coverage ≥85% sobre el renderer nuevo. +**Criterio de salida**: con `integrations.knowledge_plane.enabled: true`, el renderer emite los 3 archivos; con `false`/ausente emite `[]`. Tests semánticos sobre paths + contenido de `vault/config.md`. Coverage ≥85% sobre el renderer nuevo. ### Rama G3 — `feat/g3-ingest-cli` (diferida) diff --git a/ROADMAP.md b/ROADMAP.md index 7020513..ec3f7bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,7 +49,7 @@ Estado vivo. Cada fila refleja una rama de [MASTER_PLAN.md](MASTER_PLAN.md). | `feat/f4-marketplace-public-repo` | `marketplace.json` + `release.yml` (5 jobs: version-match, selftest, build-bundle, publish-release, mirror-marketplace condicional) + `docs/RELEASE.md` runbook + bump 0.0.1→0.1.0; repo público diferido | ✅ | — (PR pendiente) | | `feat/fx-knowledge-plane-plan` | Docs-only: abre FASE G en MASTER_PLAN (capa opcional knowledge plane) | ✅ | cc7d2c3 (#14) | | `feat/g1-knowledge-plane-contract` | Contrato tool-agnostic (vault/raw, vault/wiki, vault/config.md) + opt-in questionnaire + `docs/KNOWLEDGE_PLANE.md` + `.claude/rules/knowledge-plane.md` + schema field boolean | ✅ | — (PR pendiente) | -| `feat/g2-adapter-obsidian-reference` | Primer reference adapter: esqueleto `vault/` + Obsidian Web Clipper | ⏳ | — | +| `feat/g2-adapter-obsidian-reference` | Renderer `knowledge-plane-vault.ts` + `templates/vault/config.md.hbs` + grupo `knowledgePlaneRenderers`; opt-in gate; Obsidian Web Clipper como reference adapter; 19 tests nuevos (538 total) | ✅ | — (PR pendiente) | | `feat/g3-ingest-cli` | Stub CLI `pos knowledge ingest` (diferida) | ⏳ | — | | `feat/g4-wiki-lint` | Skill `/pos:knowledge-lint` (diferida) | ⏳ | — | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ecde138..f5e3d0a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,7 +32,9 @@ El meta-repo nunca ejecuta código del proyecto destino. El proyecto destino nun > **Opcional.** Capa extra mountable *dentro* del repo generado, adoptable vía opt-in del questionnaire. > Contrato fijado en G1 — ver especificación completa en [docs/KNOWLEDGE_PLANE.md](KNOWLEDGE_PLANE.md). -> Renderer y esqueleto `vault/` en G2 (no implementados todavía). +> Renderer `knowledge-plane-vault.ts` entregado en G2: con `integrations.knowledge_plane.enabled: true`, +> el generador emite `vault/config.md` + `vault/raw/.gitkeep` + `vault/wiki/.gitkeep`; con el flag a `false` +> (default) no se emite nada. Obsidian Web Clipper documentado en `vault/config.md` como reference adapter — no contrato base. ``` ┌────────────────────────────────────────┐ diff --git a/generator/renderers/index.test.ts b/generator/renderers/index.test.ts index 11ef6f9..fe864bd 100644 --- a/generator/renderers/index.test.ts +++ b/generator/renderers/index.test.ts @@ -3,6 +3,7 @@ import { allRenderers, cicdRenderers, coreDocRenderers, + knowledgePlaneRenderers, policyAndRulesRenderers, skillsHooksRenderers, testsHarnessRenderers, @@ -86,6 +87,24 @@ describe("renderers/index — skillsHooksRenderers (C5)", () => { }); }); +describe("renderers/index — knowledgePlaneRenderers (G2)", () => { + it("exposes exactly 1 renderer (G2 scope: vault skeleton)", () => { + expect(knowledgePlaneRenderers).toHaveLength(1); + }); + + it("is frozen to prevent accidental mutation", () => { + expect(Object.isFrozen(knowledgePlaneRenderers)).toBe(true); + }); + + it("is included in allRenderers", () => { + const kpFns = [...knowledgePlaneRenderers]; + const allFns = [...allRenderers]; + for (const fn of kpFns) { + expect(allFns).toContain(fn); + } + }); +}); + const ALL_RENDERERS_EXPECTED_PATHS: ReadonlyArray = [ [ "nextjs-app", diff --git a/generator/renderers/index.ts b/generator/renderers/index.ts index 506e4bc..57b3005 100644 --- a/generator/renderers/index.ts +++ b/generator/renderers/index.ts @@ -10,6 +10,7 @@ import { render as renderRules } from "./rules.ts"; import { render as renderTests } from "./tests.ts"; import { render as renderCiCd } from "./ci-cd.ts"; import { render as renderSkillsHooksSkeleton } from "./skills-hooks-skeleton.ts"; +import { render as renderKnowledgePlaneVault } from "./knowledge-plane-vault.ts"; export const coreDocRenderers: readonly Renderer[] = Object.freeze([ renderClaudeMd, @@ -37,10 +38,15 @@ export const skillsHooksRenderers: readonly Renderer[] = Object.freeze([ renderSkillsHooksSkeleton, ]); +export const knowledgePlaneRenderers: readonly Renderer[] = Object.freeze([ + renderKnowledgePlaneVault, +]); + export const allRenderers: readonly Renderer[] = Object.freeze([ ...coreDocRenderers, ...policyAndRulesRenderers, ...testsHarnessRenderers, ...cicdRenderers, ...skillsHooksRenderers, + ...knowledgePlaneRenderers, ]); diff --git a/generator/renderers/knowledge-plane-vault.test.ts b/generator/renderers/knowledge-plane-vault.test.ts new file mode 100644 index 0000000..690bc84 --- /dev/null +++ b/generator/renderers/knowledge-plane-vault.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { render } from "./knowledge-plane-vault.ts"; +import type { Profile } from "../lib/profile-model.ts"; + +function makeProfileWithAnswers(answers: Record): Profile { + return { + meta: { version: "1", profileName: "test", profileDescription: "test" }, + answers, + placeholders: [], + }; +} + +function makeProfile(enabled: unknown): Profile { + return makeProfileWithAnswers( + enabled === undefined ? {} : { integrations: { knowledge_plane: { enabled } } } + ); +} + +const profileEnabled = makeProfile(true); +const profileDisabled = makeProfile(false); +const profileAbsent = makeProfile(undefined); +const profileIntegrationsNoKP = makeProfileWithAnswers({ integrations: {} }); + +describe("renderers/knowledge-plane-vault — opt-in gate", () => { + it("returns [] when enabled is false", () => { + expect(render(profileDisabled)).toEqual([]); + }); + + it("returns [] when enabled is absent (default false)", () => { + expect(render(profileAbsent)).toEqual([]); + }); + + it("returns [] when integrations exists but knowledge_plane key is absent", () => { + expect(render(profileIntegrationsNoKP)).toEqual([]); + }); + + it("returns exactly 3 FileWrite entries when enabled is true", () => { + expect(render(profileEnabled)).toHaveLength(3); + }); +}); + +describe("renderers/knowledge-plane-vault — emitted paths", () => { + it("emits vault/config.md", () => { + const paths = render(profileEnabled).map((f) => f.path); + expect(paths).toContain("vault/config.md"); + }); + + it("emits vault/raw/.gitkeep", () => { + const paths = render(profileEnabled).map((f) => f.path); + expect(paths).toContain("vault/raw/.gitkeep"); + }); + + it("emits vault/wiki/.gitkeep", () => { + const paths = render(profileEnabled).map((f) => f.path); + expect(paths).toContain("vault/wiki/.gitkeep"); + }); + + it("does NOT emit vault/schema.md (naming: config.md wins per G1 rule)", () => { + const paths = render(profileEnabled).map((f) => f.path); + expect(paths).not.toContain("vault/schema.md"); + }); +}); + +describe("renderers/knowledge-plane-vault — .gitkeep content", () => { + it("vault/raw/.gitkeep has empty content (single newline)", () => { + const file = render(profileEnabled).find((f) => f.path === "vault/raw/.gitkeep")!; + expect(file.content).toBe("\n"); + }); + + it("vault/wiki/.gitkeep has empty content (single newline)", () => { + const file = render(profileEnabled).find((f) => f.path === "vault/wiki/.gitkeep")!; + expect(file.content).toBe("\n"); + }); +}); + +describe("renderers/knowledge-plane-vault — vault/config.md content", () => { + it("contains ## Propósito section", () => { + const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; + expect(file.content).toMatch(/^## Propósito/m); + }); + + it("contains ## Estructura section", () => { + const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; + expect(file.content).toMatch(/^## Estructura/m); + }); + + it("contains ## Convenciones raw section", () => { + const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; + expect(file.content).toMatch(/^## Convenciones raw/m); + }); + + it("contains ## Convenciones wiki section", () => { + const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; + expect(file.content).toMatch(/^## Convenciones wiki/m); + }); + + it("contains ## Ingestor recomendado section", () => { + const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; + expect(file.content).toMatch(/^## Ingestor recomendado/m); + }); + + it("mentions Obsidian Web Clipper as reference ingestor", () => { + const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; + expect(file.content).toMatch(/Obsidian Web Clipper/i); + }); + + it("explicitly states the adapter is a reference, not a base contract", () => { + const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; + expect(file.content).toMatch(/referencia|reference/i); + }); +}); + +describe("renderers/knowledge-plane-vault — structural invariants", () => { + it("every emitted file ends with a trailing newline", () => { + for (const f of render(profileEnabled)) { + expect(f.content.endsWith("\n"), `${f.path} must end with \\n`).toBe(true); + } + }); + + it("is deterministic: byte-identical output for equivalent profiles", () => { + expect(JSON.stringify(render(makeProfile(true)))).toBe( + JSON.stringify(render(makeProfile(true))) + ); + }); +}); diff --git a/generator/renderers/knowledge-plane-vault.ts b/generator/renderers/knowledge-plane-vault.ts new file mode 100644 index 0000000..81d9051 --- /dev/null +++ b/generator/renderers/knowledge-plane-vault.ts @@ -0,0 +1,20 @@ +import { loadTemplate } from "../lib/template-loader.ts"; +import type { FileWrite, Profile, Renderer } from "../lib/render-pipeline.ts"; + +const configTemplate = loadTemplate("vault/config.md.hbs"); + +export const render: Renderer = (profile: Profile): FileWrite[] => { + const answers = profile.answers as Record; + const integrations = (answers.integrations ?? {}) as Record; + const kp = (integrations.knowledge_plane ?? {}) as Record; + + if (kp.enabled !== true) { + return []; + } + + return [ + { path: "vault/config.md", content: configTemplate(profile) }, + { path: "vault/raw/.gitkeep", content: "\n" }, + { path: "vault/wiki/.gitkeep", content: "\n" }, + ]; +}; diff --git a/templates/vault/config.md.hbs b/templates/vault/config.md.hbs new file mode 100644 index 0000000..32d08a0 --- /dev/null +++ b/templates/vault/config.md.hbs @@ -0,0 +1,38 @@ +# vault/config.md — configuración de este vault + +> Fuente de verdad de las convenciones adoptadas por este proyecto. +> Editar este archivo cuando cambien las convenciones de nomenclatura o tags. + +## Propósito + +TODO: Describir el propósito de esta capa de conocimiento en el contexto del proyecto. +Ejemplo: "Centralizar decisiones de diseño, referencias técnicas y síntesis de investigación." + +## Estructura + +``` +vault/ +├── raw/ fuentes inmutables (artículos, transcripciones, specs) +├── wiki/ síntesis y conocimiento procesado +└── config.md este archivo +``` + +## Convenciones raw/ + +- Un archivo por fuente. Nombre en kebab-case descriptivo. +- Metadatos mínimos al inicio: `source:`, `date:`, `tags:`. +- TODO: Definir convención de subdirectorios si se adopta (por defecto: sin subdirectorios). + +## Convenciones wiki/ + +- Un archivo por concepto o decisión de diseño. +- TODO: Definir mapa de categorías o tags adoptado en este proyecto. +- Las páginas sin raw correspondiente son válidas (conocimiento sintetizado ad hoc). + +## Ingestor recomendado + +**Referencia**: [Obsidian](https://obsidian.md) + [Obsidian Web Clipper](https://obsidian.md/clipper) +guarda páginas web como Markdown en el vault con un clic. + +Este adapter es una **referencia**, no un contrato base. El vault es file-based y tool-agnostic: +cualquier editor Markdown (Logseq, Foam, Zettlr, plain-text) es compatible por construcción.