Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .claude/rules/knowledge-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggestion to “escapar explícitamente con {{{{raw}}}}” is inaccurate: Handlebars raw blocks prevent evaluation of template code but do not HTML-escape interpolated values, and there is no escape helper registered today. Since loadTemplate compiles with noEscape: true, the actionable options are to (a) introduce an escape/sanitization helper and use it for any interpolations, or (b) compile these vault templates with escaping enabled (separate loader or option override) if you want safe-by-default {{...}} behavior.

Suggested change
y escapar explícitamente con `{{{{raw}}}}` o sanitizar antes de inyectar.
y usar una mitigación real: sanitizar/escapar el valor antes de inyectarlo (o mediante
un helper de escape registrado), o compilar esos templates con escaping habilitado si
se quiere que `{{...}}` sea seguro por defecto.

Copilot uses AI. Check for mistakes.

5. **Opt-in gate**: el renderer sólo emite archivos cuando
`answers["integrations.knowledge_plane.enabled"] === true`. Con `false` (default),
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opt-in gate expression here uses answers["integrations.knowledge_plane.enabled"], but profiles are expanded from dotted keys into a nested object by buildProfile (so the runtime shape is answers.integrations.knowledge_plane.enabled). As written, this guidance will lead to incorrect implementations; update it to reference the nested access pattern (or a dedicated getNested(answers, "integrations.knowledge_plane.enabled") helper if you prefer dotted-path access).

Suggested change
`answers["integrations.knowledge_plane.enabled"] === true`. Con `false` (default),
`answers.integrations.knowledge_plane.enabled === true`. Con `false` (default),

Copilot uses AI. Check for mistakes.
la función devuelve `[]`.

Expand Down
5 changes: 3 additions & 2 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 realcontrato 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.
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 14 additions & 10 deletions MASTER_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tmp>` 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)

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | ⏳ | — |

Expand Down
4 changes: 3 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

```
┌────────────────────────────────────────┐
Expand Down
19 changes: 19 additions & 0 deletions generator/renderers/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
allRenderers,
cicdRenderers,
coreDocRenderers,
knowledgePlaneRenderers,
policyAndRulesRenderers,
skillsHooksRenderers,
testsHarnessRenderers,
Expand Down Expand Up @@ -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<readonly [string, readonly string[]]> = [
[
"nextjs-app",
Expand Down
6 changes: 6 additions & 0 deletions generator/renderers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
]);
125 changes: 125 additions & 0 deletions generator/renderers/knowledge-plane-vault.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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)))
);
});
});
20 changes: 20 additions & 0 deletions generator/renderers/knowledge-plane-vault.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const integrations = (answers.integrations ?? {}) as Record<string, unknown>;
const kp = (integrations.knowledge_plane ?? {}) as Record<string, unknown>;

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" },
];
};
Loading
Loading