diff --git a/.claude/rules/hooks.md b/.claude/rules/hooks.md index c13d21f..b32982d 100644 --- a/.claude/rules/hooks.md +++ b/.claude/rules/hooks.md @@ -291,11 +291,20 @@ Si un hook futuro necesita otra dep no-stdlib, abrir un commit propio que la jus - Loader con suite propia: `hooks/tests/test_lib_policy.py` (57 casos — cache behavior, los 3 accessors con happy path + missing section + missing file, `derive_test_pair` por label, invalidación del cache via `reset_cache()`, precedencia del first-match-wins en conditional rules). - Tests de los 3 hooks consumidores: fixture de repo escribe `policy.yaml` a disco + autouse `_reset_policy_cache` para isolation. Tests unitarios de los consumidores aceptan la dataclass tipada como argumento (`check_docs_sync(files, rules)`, `match_triggers(paths, trigger)`). Constantes hardcoded que eran mirror de `policy.yaml` (`TestIsEnforcedUnit`, `TestExpectedTestPairUnit`, `TestPolicyConstants`) eliminadas por redundantes con el loader test. -### Drift temporal meta-repo ↔ template (abierto tras D5b) +### Drift temporal meta-repo ↔ template — cerrado en `refactor/template-policy-d5b-migration` -Tras D5b, `policy.yaml` del meta-repo tiene el shape nuevo (`pre_write.enforced_patterns` + `docs_sync_conditional.hooks/**` con `excludes: ["hooks/tests/**"]`). **`templates/policy.yaml.hbs` + `generator/renderers/policy.ts` + snapshots NO fueron tocados** en esta rama. Proyectos generados con `pos` hoy emiten un `policy.yaml` con el shape anterior. Reconciliar (template + renderer + snapshots + `pyyaml` en requirements-dev de stacks Python generados) queda diferido a una rama propia post-D6. +Tras D5b el `policy.yaml` del meta-repo quedó con el shape nuevo, mientras que `templates/policy.yaml.hbs` siguió emitiendo el shape pre-D5b. El drift se cerró en la rama `refactor/template-policy-d5b-migration` migrando el template a un shape contractual con el loader (`hooks/_lib/policy.py`). Decisiones ratificadas: -**No leer esta rule como "el template ya consume el loader"** — explícitamente no lo hace. Ver [MASTER_PLAN.md § Rama D5b](../../MASTER_PLAN.md), [ROADMAP.md § refactor/d5-policy-loader](../../ROADMAP.md), [HANDOFF.md §11](../../HANDOFF.md), [docs/ARCHITECTURE.md § 7](../../docs/ARCHITECTURE.md#capa-1-hooks). +- `lifecycle.pre_write.enforced_patterns: []` — clave presente, lista vacía. Proyectos generados **no** heredan los enforcement patterns del meta-repo; el loader devuelve `PreWriteRules(())` (no `None`). +- `skills_allowed` — clave **omitida** en el template. Loader devuelve `None` (deferred), reflejando el estado del meta-repo hasta que se pueble la allowlist por proyecto. +- `lifecycle.pre_compact.persist` — los 3 items canónicos (`decisions_in_flight`, `phase_minus_one_state`, `unsaved_pattern_candidates`). +- `lifecycle.post_merge.skills_conditional[0].trigger` — globs genéricos conservadores (`src/**`, `lib/**`, `*.py`, `package.json`, `pyproject.toml`) + `skip_if_only` para docs + `min_files_changed: 2`. Suficiente para que `post_merge_trigger()` devuelva un dataclass tipado; los proyectos afinan cuando estabilicen convenciones. + +Contrato lockdown: `bin/tests/test_template_loader_contract.py` corre los 5 accessors del loader real contra el output del generador real sobre los 3 profiles canónicos (cli-tool, nextjs-app, agent-sdk). Cualquier regresión del template que rompa el shape esperado se detecta ahí — no hay TS-side reimplementation del contrato. + +Selftest cleanup: `bin/_selftest.py` removió los overlays de D4 y D5 (template ahora emite baseline matching). D3 y D6 mantienen overlays mínimos por diseño explícito (A1 emite `enforced_patterns: []` + A2 omite `skills_allowed`); el comentario en cada escenario documenta la razón. + +Ver [MASTER_PLAN.md § Rama F3b](../../MASTER_PLAN.md), [ROADMAP.md fila refactor/template-policy-d5b-migration](../../ROADMAP.md), [HANDOFF.md §1](../../HANDOFF.md), [docs/ARCHITECTURE.md § 10 Selftest end-to-end](../../docs/ARCHITECTURE.md). ## Sexto hook entregado — `hooks/pre-compact.py` (Rama D6) diff --git a/HANDOFF.md b/HANDOFF.md index 3d87fed..e185ec4 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,7 +5,8 @@ ## 1. Snapshot - Repo: `project-operating-system` (plugin `pos`). -- Rama actual: **F4 ✅ PR pendiente** (`feat/f4-marketplace-public-repo`, en revisión docs-sync — última rama de Fase F). Anterior: **F3 ✅ PR #27** (mergeada). Siguiente: **`refactor/template-policy-d5b-migration`** (rama propia post-F4 para cerrar drift template post-D5b) o cualquier rama Fase G — Fase F cerrada tras F4. +- Rama actual: **`refactor/template-policy-d5b-migration` ✅ PR pendiente** (sub-rama refactor post-F4 que cierra el drift `meta-repo ↔ template` documentado desde D5b y reforzado en F3). Anterior: **F4 ✅ PR pendiente** (`feat/f4-marketplace-public-repo`, en revisión docs-sync — última rama de Fase F propiamente dicha). Siguiente: **`feat/fx-knowledge-plane-plan`** (entry-point Fase G, opcional, docs-only) o cualquier carry-over restante post-F4 — Fase F + drift template cerrados. +- `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. - F2 entregó: `agents/pos-code-reviewer.md` + `agents/pos-architect.md` (plugin subagents primitive-correct con namespace `pos-*`); flips de `pre-commit-review` y `compound` a los nuevos consumidores; 26 contract tests parametrizados (`agents/tests/test_agent_frontmatter.py`). **No** toca `policy.yaml` (`agents_allowed` diferido). `auditor` diferido (sin consumer real, regla #7). @@ -102,7 +103,7 @@ Ejecuta §2.1 Fase -1 completo. Espera aprobación explícita antes de `git chec - El hook `pre-write-guard.py` **ya está vivo** desde D3: PreToolUse(Write) blocker. Bloquea con exit 2 la creación de archivos en paths enforced (`hooks/*.py` top-level + `generator/**/*.ts` excluyendo tests/fixtures) sin test pair co-located. Los writes sobre archivos existentes en esos paths enforced sí pasan, pero siguen logueándose (allow / audit trail del flujo de edición). El pass-through silencioso (sin log) aplica solo a `hooks/_lib/**`, tests/docs/templates/meta y paths fuera del repo. Bypass legítimo: crear primero `hooks/tests/test_.py` o `.test.ts` con un test que falle (RED), luego escribir la implementación. - El hook `pre-pr-gate.py` **ya está vivo** desde D4: PreToolUse(Bash) blocker sobre `gh pr create` únicamente. Resuelve base con `git merge-base HEAD main` y calcula archivos tocados con `git diff --name-only HEAD`. Bloquea con exit 2 cuando ese diff no contiene los docs exigidos (required `ROADMAP.md` + `HANDOFF.md`; conditional por prefijo: `generator/**` | `hooks/**` excl. `hooks/tests/` | `.claude/patterns/**` → `docs/ARCHITECTURE.md`; `skills/**` → `.claude/rules/skills-map.md`). Skip advisory (pass-through + log explícito en hook log, no en phase-gates) en `main` / `master` / HEAD detached / cwd no-git / main borrada localmente / `git diff` subprocess falla (`diff_files() is None`). Empty diff (`[]`) → deny dedicado con reason `empty PR`, separado textualmente del reason docs-sync. 3 entradas advisory `deferred` (skills_required / ci_dry_run_required / invariants_check) se loguean en cada decisión real como scaffold activable sin cambio de shape cuando sus ramas dedicadas aporten sustrato. Reglas hardcoded (mirror de `policy.yaml.lifecycle.pre_pr.docs_sync_required` + `docs_sync_conditional`); divergencia deliberada D4: la lista `hooks/**` de la policy es uniforme, el hook excluye `hooks/tests/` — convergencia diferida a la rama policy-loader. Migración a parser declarativo en esa misma rama (junto con los paths hardcoded de D3). - **Loader declarativo `hooks/_lib/policy.py`** vivo desde D5b: los tres hooks D3/D4/D5 ya **no** hardcodean policy — leen via `docs_sync_rules()` / `post_merge_trigger()` / `pre_write_rules()`. Failure mode canónico (c.2): `policy.yaml` ausente o corrupto → loader devuelve `None` → hook degrada a pass-through advisory con `status: policy_unavailable` en su propio log. Nunca deny blind. Consumo único (stdlib + `pyyaml==6.0.2`, primera dependencia no-stdlib en `hooks/_lib/`, justificada en kickoff D5b). Ver `.claude/rules/hooks.md § Policy loader`. -- **Drift temporal meta-repo ↔ template** abierto tras D5b: `policy.yaml` (meta-repo) tiene el shape nuevo (`pre_write.enforced_patterns` + `docs_sync_conditional.hooks/**` con `excludes: ["hooks/tests/**"]`); `templates/policy.yaml.hbs` y el renderer `generator/renderers/policy.ts` **no** — un proyecto generado hoy con `pos` emite un `policy.yaml` con el shape previo. Reconciliación con stub abierto en `refactor/template-policy-d5b-migration` (ver [MASTER_PLAN.md § Rama F3b](MASTER_PLAN.md)) — no bloquea F4 ni Fase G; F3 lo evade vía overlays por escenario en `bin/_selftest.py`. +- **Drift temporal meta-repo ↔ template** ✅ **cerrado** en `refactor/template-policy-d5b-migration` (sub-rama post-F4): `templates/policy.yaml.hbs` migrado al shape contractual con loader (`pre_write.enforced_patterns: []` + `pre_pr.docs_sync_conditional: []` + `pre_compact.persist` con 3 items canónicos + `post_merge.skills_conditional[0].trigger` con globs genéricos conservadores; `skills_allowed` permanece omitido por diseño). Lockdown vía `bin/tests/test_template_loader_contract.py` que corre los 5 accessors reales del loader sobre output del generator real (cli-tool / nextjs-app / agent-sdk). Overlays F3 D4+D5 removidos; D3+D6 mantienen overlays mínimos por A1+A2. - El hook `post-action.py` **ya está vivo** desde D5: PostToolUse(Bash) hook **non-blocking** (exit 0 siempre; no emite `permissionDecision`). Detección jerárquica 2 tiers — Tier 1 (`shlex.split`): matcher A `git merge ` (excluye `--abort/--quit/--continue/--skip`), matcher C `git pull` (excluye `--rebase/-r`). Tier 2 (`git reflog HEAD -1 --format=%gs`): confirma `"merge "` (A) o `"pull:" | "pull "` y no `"pull --rebase"` (C). `gh pr merge` (matcher B) descartado en Fase -1 por ausencia de `tool_response.exit_code` garantizado. Con ambos tiers confirmados: `git diff --name-only HEAD@{1} HEAD` + `fnmatch` contra `TRIGGER_GLOBS` mirror de `policy.yaml.lifecycle.post_merge.skills_conditional[0]` (`generator/lib/**`, `generator/renderers/**`, `hooks/**`, `skills/**`, `templates/**/*.hbs`), respetando `SKIP_IF_ONLY_GLOBS` (`docs/**`, `*.md`, `.claude/patterns/**`) y `MIN_FILES_CHANGED = 2`. Si matchea, emite `hookSpecificOutput.additionalContext` sugiriendo `/pos:compound` (4 líneas, cap 3 paths + `(+N more)`). **Nunca dispatcha la skill** — advisory-only; D5 sólo sugiere, E3a la entrega. Double log: `post-action.jsonl` (4 status distinguidos: `tier2_unconfirmed`, `diff_unavailable`, `confirmed_no_triggers`, `confirmed_triggers_matched`) + `phase-gates.jsonl` (evento `post_merge`, sólo en los dos status confirmed — los advisory tier2/diff no cruzan la puerta del lifecycle). Pass-throughs (Tier 1 no matchea) NO loguean. Reuso `_lib/`: `append_jsonl` + `now_iso`. Hardcode mirror de `policy.yaml` (segunda repetición tras D4) — regla #7 CLAUDE.md **cumplida dos veces** para el parser declarativo, precondición ready para la rama policy-loader. - El hook `pre-compact.py` **ya está vivo** desde D6: PreCompact **informative** (shape D2) — exit 0 siempre, nunca `permissionDecision`. Lee `pre_compact_rules(cwd).persist` del loader (D5b) y emite `additionalContext` con checklist de items a persistir antes del compact. Failure mode canónico (c.2): policy ausente o sección `lifecycle.pre_compact` ausente → loader devuelve `None` → hook emite contexto informativo mínimo que señala policy no disponible + log `status: policy_unavailable`. Safe-fail informative ante stdin corrupto: contexto degradado que señala el error de payload + log `status: payload_error`. Double log: `pre-compact.jsonl` siempre; `phase-gates.jsonl` evento `pre_compact` **solo** en happy path. (Wording exacto del contexto no es contrato — no se citan strings; ver `hooks/pre-compact.py` y sus tests si algún consumidor necesita inspeccionarlo.) - El hook `stop-policy-check.py` **ya está vivo** desde D6 como **scaffold activable** — shape D1 blocker (safe-fail canónico deny en payload malformado), pero **sin enforcement en producción hoy**. Lee `skills_allowed_list(cwd)` como tri-estado: `None` → `status: deferred` + pass-through silencioso (sección `skills_allowed` ausente del `policy.yaml` del meta-repo hoy); `SKILLS_ALLOWED_INVALID` → `status: policy_misconfigured` + pass-through (clave presente pero mal formada — **observable, NO silenciosa**: un typo en la policy ya no apaga enforcement como si fuera deferred); `()` → explicit deny-all; `tuple[str, ...]` poblada → enforcement live. Las invocaciones se leen de `.claude/logs/skills.jsonl` **filtradas por el `session_id`** del payload Stop actual (entradas de sesiones anteriores o sin `session_id` se ignoran); sin `session_id` en el payload → deny safe-fail (no se puede scopiar enforcement). Double log solo en decisiones reales (`deferred`/`policy_misconfigured`/`policy_unavailable` van solo al hook log, no cruzan `phase-gates.jsonl`). Framing **anti-sobrerrepresentación**: hoy el hook NO protege nada en producción; la entrega D6 aporta el shape y la suite de tests que valida el contrato — el switch-on real llega cuando una skill poblada declare su allowlist. @@ -135,9 +136,8 @@ Hasta F1 el plugin reusaba subagents built-in; desde F2 los críticos son propio ## 9. Próxima rama -Fase F **cerrada** tras F4. Carry-overs abiertos: +Fase F + drift template **cerrados** tras F4 + `refactor/template-policy-d5b-migration`. Carry-overs abiertos: -- **`refactor/template-policy-d5b-migration`** — rama propia para migrar `templates/policy.yaml.hbs` + renderer + snapshots al shape post-D5b (drift documentado desde D5b/F3; F3 lo evade vía overlays por escenario en `bin/_selftest.py`). No bloquea Fase G. - **`feat/fx-knowledge-plane-plan`** (Fase G entry-point, opcional): docs-only — abre Fase G en MASTER_PLAN.md como capa knowledge plane opt-in. Sin fecha — el usuario decide cuándo arrancar. - **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. diff --git a/MASTER_PLAN.md b/MASTER_PLAN.md index 77d473e..66238fc 100644 --- a/MASTER_PLAN.md +++ b/MASTER_PLAN.md @@ -774,47 +774,44 @@ Esperar aprobación explícita del usuario. Con OK → crear marker + rama. **Carry-overs a F4**: - `.github/workflows/release.yml` queda como entrega de F4 (no F3). El `selftest` job se reusará en `release.yml` como gate antes de publicar tag. -- Drift `templates/policy.yaml.hbs` → shape post-D5b queda diferido (no bloquea F4 ni Fase G). Stub abierto en `refactor/template-policy-d5b-migration` (ver siguiente sección). +- Drift `templates/policy.yaml.hbs` → shape contractual con loader **cerrado** en sub-rama `refactor/template-policy-d5b-migration` (ver § siguiente). -### Rama F3b — `refactor/template-policy-d5b-migration` (stub) +### Rama F3b — `refactor/template-policy-d5b-migration` ✅ PR pendiente -**Status**: stub abierto post-F3. Sub-rama refactor que cierra el drift `meta-repo ↔ template` documentado en D5b (rama D5b decidió explícitamente no migrar el template) y reforzado en F3 (cada escenario sobre-escribe `synthetic/policy.yaml` para evadir el drift). No bloquea F4 ni Fase G — se programa cuando un consumer real (`pos:audit-session` corriendo sobre proyecto generado, o futuro test contractual del template) requiera el shape post-D5b en el output del generator. +**Status**: ✅ cerrada post-F4. Cierra el drift `meta-repo ↔ template` documentado desde D5b y reforzado en F3 (cada escenario sobre-escribía `synthetic/policy.yaml` para evadir el shape pre-D5b). Scope literal cumplido: `policy.yaml` emitido por el template parsea con el loader actual de `hooks/_lib/policy.py` sin devolver `None` en los 5 accessors loader-relevant. -**Scope previsto**: +**Decisiones ratificadas en Fase -1 (A1–A6)**: -- `templates/policy.yaml.hbs` — migrar a shape post-D5b: bloque `pre_write.enforced_patterns` (lista, no flat) + `lifecycle.pre_pr.docs_sync_conditional[].excludes` + cualquier otra sección que el loader (`hooks/_lib/policy.py`) consuma vía dataclass tipada. -- `generator/renderers/policy.ts` — adaptar el render para emitir el shape nuevo. Validar que las 3 ramas del profile (`nextjs-app` / `cli-tool` / `agent-sdk`) compilan sin patches manuales. -- `generator/__snapshots__//policy.yaml.snap` — re-snapshotear los 3 perfiles canónicos. -- `templates/requirements-dev.txt.hbs` (o equivalente del stack Python emitido) — añadir `pyyaml==6.0.2` cuando el profile sea Python, consistente con el meta-repo (loader depende de pyyaml). -- `bin/_selftest.py` — limpieza opcional: una vez el template emite shape post-D5b, los escenarios D3/D4/D5 pueden simplificarse (sólo override del campo específico, no la sección entera). Reabrir las constants `POLICY_PRE_WRITE_ONLY` / `POLICY_DOCS_SYNC_ONLY` / `POLICY_POST_MERGE_ONLY` para reducir. -- Tests: - - `generator/lib/__tests__/policy.test.ts` — actualizar fixtures + asserciones del renderer. - - `bin/tests/test_selftest_scenarios.py` — debe seguir verde sin cambios (los hooks consumen el loader, no el shape literal). Si rompe, ahí está la regresión que justifica la rama. - - Considerar añadir un test contractual nuevo: render del policy del profile X parsea limpio con `hooks/_lib/policy.py.load_policy` (cierra el drift por construcción). +- **A1** — `lifecycle.pre_write.enforced_patterns: []`. Clave presente, lista vacía. Loader devuelve `PreWriteRules(())`, no `None`. Proyectos generados **no** heredan los enforcement patterns del meta-repo; declaran los suyos cuando sus convenciones estabilicen. +- **A2** — `skills_allowed`: clave **omitida** en el template. Loader devuelve `None` (deferred), reflejando el estado del meta-repo hasta que cada proyecto pueble su propia allowlist. **No** se emite `skills_allowed: []` (eso sería deny-all explícito, semánticamente distinto). +- **A3** — `lifecycle.pre_compact.persist`: los 3 items canónicos (`decisions_in_flight`, `phase_minus_one_state`, `unsaved_pattern_candidates`). +- **A4** — `lifecycle.post_merge.skills_conditional[0].trigger`: globs genéricos conservadores stack-agnostic (unión TS+Python: `src/**`, `lib/**`, `*.py`, `package.json`, `pyproject.toml`) + `skip_if_only` para docs (`docs/**`, `*.md`, `.claude/patterns/**`) + `min_files_changed: 2`. Suficiente para que `post_merge_trigger()` devuelva un dataclass tipado. Comentario in-template declara que los proyectos afinan cuando sus convenciones estabilicen. +- **A5** — 2 commits: RED contract test (un único archivo Python-side parametrizado por profile) → GREEN bundle único (template + 3 snapshots + cleanup overlays selftest). NO commit-por-profile. +- **A6** — contract test Python-side. `bin/tests/test_template_loader_contract.py` corre los accessors reales de `hooks/_lib/policy.py` sobre output del generator real (`npx tsx generator/run.ts --profile cli-tool|nextjs-app|agent-sdk.yaml`). NO TS-side reimplementation del contrato. -**Contexto a leer**: +**Ajustes durante Fase -1 (3 ratificados por el usuario, anti-overselling)**: + +- **NO crear `templates/requirements-dev.txt.hbs`**: el contract test no demostró necesidad real (pyyaml ya está en el meta-repo durante test; los proyectos generados materializarán su manifest cuando D5b/F4 evolucione el harness Python). +- **Framing literal**: scope descrito como "policy.yaml generado parsea con el loader actual" — **no** como "7 hooks blackbox" ni "end-to-end completo". Overselling implicaciones downstream que no eran parte de este PR fue corregido. +- **post_merge trigger**: globs genéricos suficientes para remover overlays F3 D4+D5; **no** un diseño perfecto de triggers para cualquier proyecto futuro. -- `policy.yaml` (meta-repo, shape post-D5b) vs `templates/policy.yaml.hbs` (shape pre-D5b) — diff manual. -- `hooks/_lib/policy.py § dataclasses + accessors` — contrato que el template debe cumplir. -- `bin/_selftest.py § POLICY_*_ONLY constants` — overrides actuales por escenario, son la referencia de qué shape espera cada hook. -- `MASTER_PLAN.md § Rama D5b` — decisiones (b.1 strings/globs en YAML, c.2 failure mode `None`). -- `generator/renderers/policy.ts` + sus tests + snapshots actuales. +**Archivos entregados**: -**Decisiones a cerrar en Fase -1**: +- `bin/tests/test_template_loader_contract.py` (NEW, 182 líneas) — fixture `module-scoped` parametrizada sobre los 3 profiles canónicos (cli-tool, nextjs-app, agent-sdk) que genera el proyecto sintético una vez por profile. 5 clases, 9 test methods, parametrizadas × 3 profiles = 27 cases: `TestPreWriteRules` (returns dataclass + empty tuple), `TestDocsSyncRules` (returns dataclass + ROADMAP/HANDOFF en baseline), `TestPostMergeTrigger` (returns dataclass + min_files_changed int no bool), `TestPreCompactRules` (returns dataclass + 3 items canónicos), `TestSkillsAllowed` (returns None per A2). +- `templates/policy.yaml.hbs` (+26 líneas) — bloques añadidos: `pre_write.enforced_patterns: []` + `pre_pr.docs_sync_conditional: []` + `post_merge.skills_conditional[0].trigger` con globs genéricos + `pre_compact.persist` con 3 items. `skills_allowed` permanece omitido por A2. +- `generator/__snapshots__/{cli-tool,nextjs-app,agent-sdk}/policy.yaml.snap` — regenerados (cada uno +26 líneas, byte-identical determinismo). +- `bin/_selftest.py` — overlays D4 (`POLICY_DOCS_SYNC_ONLY`) y D5 (`POLICY_POST_MERGE_ONLY`) **removidos**; D5 refactorizado para commitear `src/feature.py` + `src/helper.py` (matchea trigger genérico `src/**` + `min_files_changed: 2`); D3 conserva `POLICY_PRE_WRITE_ONLY` por A1 (template emite lista vacía, hay que inyectar entry no vacío para ejercer deny path); D6 conserva `POLICY_SKILLS_ALLOWED_ONLY` por A2 (template omite la clave, hay que inyectar lista para ejercer deny path). -- Ámbito: ¿migrar todos los profiles a la vez o uno por commit (pattern incremental F3)? Probablemente uno por commit: `cli-tool` primero (es el que usa el selftest), luego `nextjs-app` y `agent-sdk` con re-snapshot. -- ¿Añadir test contractual `template render → loader parse` o dejarlo implícito por el selftest? El test contractual cierra el drift por construcción y pertenece a `generator/lib/__tests__/`. -- ¿Limpieza de overlays en `bin/_selftest.py` se hace en esta rama o se difiere? Probablemente en esta rama — la justificación de la rama es exactamente que los overlays dejen de ser necesarios. +**Contrato fijado por la suite**: -**Criterio de salida (preliminar)**: +- `bin/tests/test_template_loader_contract.py` corre en CI sobre los 3 profiles canónicos. Cualquier modificación futura del template que rompa los 5 accessors del loader (`pre_write_rules`, `docs_sync_rules`, `post_merge_trigger`, `pre_compact_rules`, `skills_allowed_list`) cae antes del merge. +- A2 lockdown: `skills_allowed` **debe** permanecer omitido del template — `test_skills_allowed_list_is_none` falla si alguien añade la clave (incluso como `[]`). +- A1 lockdown: `enforced_patterns` **debe** permanecer presente y vacío — `test_enforced_patterns_is_empty_tuple` falla si alguien copia patrones del meta-repo o elimina la clave. +- Snapshots regenerados son la verdad; futuras modificaciones al template requieren `npx vitest run -u` explícito (o `rm` de los `.snap` afectados + re-render) con diff revisado. -- Los 3 profiles canónicos generan `policy.yaml` que parsea con `hooks/_lib/policy.py` sin warnings ni `policy_unavailable`. -- `bin/tests/test_selftest_scenarios.py` verde sin cambios funcionales (sólo simplificación de overlays si se hace). -- Snapshots actualizados con diff revisado. -- Drift `meta-repo ↔ template` cerrado en HANDOFF + ARCHITECTURE. -- Sin regresión en tests del generator ni de los 3 hooks D3/D4/D5. +**Suite global post-cierre**: **671 passed + 1 skipped** (vs main baseline 644 + 1 skip = neto +27 contract tests, sin regresión D1..D6 / E1..E3 / F1..F4). Vitest 515 / 0 fail. Selftest end-to-end 5/5 escenarios verdes sin overlays D4/D5. -**Razón para no entregarlo en F3**: F3 es selftest. Mezclar migración del template inflaría el scope, retrasaría la cobertura D-gates, y los overlays por escenario son una solución limpia y auto-contenida que **prueba** la independencia hook/loader respecto al template. Documentar el drift como abierto + abrir stub explícito (este §) es la decisión correcta. +**Criterio de salida**: 671 + 1 skip + selftest 5/5. Docs-sync dentro del PR (este § flippeado a ✅ + ROADMAP fila refactor + § progreso nuevo + carry-over post-F4 flippeado a ✅; HANDOFF §1 + §7 + §9; `docs/ARCHITECTURE.md § 10 Selftest end-to-end` drift cerrado + § 13 deferral flippeado; `.claude/rules/hooks.md § Drift cerrado`). `pre-pr-gate.py` aprueba este mismo PR — required `ROADMAP.md` + `HANDOFF.md` satisfecho. ### Rama F4 — `feat/f4-marketplace-public-repo` ✅ PR pendiente @@ -859,7 +856,7 @@ Esperar aprobación explícita del usuario. Con OK → crear marker + rama. - `audit.yml` nightly — declarado en `policy.yaml.ci_cd.workflows` desde Fase A; sin consumer activo. Reabrir en rama dedicada cuando `npm audit` + `pip-audit` + `/pos:audit-plugin --self` consuman cadencia automatizada. - `/pos:pr-description` + `/pos:release` skills — listadas en `skills-map.md § Audit + Release` como "entregado en F"; F4 cierra el flow manual sin extraer skills (regla #7 — sin repetición demostrada). Reabrir cuando el manual repita patrón. - Repo público `javiAI/pos-marketplace` — la creación es **manual**, no parte de F4. Activación del job `mirror-marketplace` requiere: (1) crear el repo, (2) `gh variable set POS_MARKETPLACE_REPO`, (3) `gh secret set POS_MARKETPLACE_TOKEN`. Runbook en `docs/RELEASE.md`. -- `refactor/template-policy-d5b-migration` — drift independiente, no bloquea F4 ni Fase G. +- `refactor/template-policy-d5b-migration` — ✅ cerrada post-F4 (sub-rama F3b). Cierre del drift `meta-repo ↔ template` documentado desde D5b. - Fase G (Knowledge Plane) — opcional, sin fecha; no afecta a F4. --- diff --git a/ROADMAP.md b/ROADMAP.md index 27517cc..94e3df3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,7 +13,7 @@ Estado vivo. Cada fila refleja una rama de [MASTER_PLAN.md](MASTER_PLAN.md). | E1 | Skills orquestación | ✅ (E1a + E1b) | | E2 | Skills calidad | ✅ (E2a + E2b) | | E3 | Skills patterns + tests | ✅ (E3a ✅, E3b ✅) | -| F | Audit + selftest + marketplace | 🔄 (F1 ✅, F2 ✅, F3 ✅, F4 ⏳) | +| F | Audit + selftest + marketplace | ✅ (F1 ✅, F2 ✅, F3 ✅, F4 ✅, F3b ✅) | | G | Knowledge Plane (opcional) | ⏳ solo planificación (scope cerrado, sin implementación) | ## Ramas @@ -45,7 +45,7 @@ Estado vivo. Cada fila refleja una rama de [MASTER_PLAN.md](MASTER_PLAN.md). | `feat/f1-skill-audit-session` | `/pos:audit-session` (read-only advisory main-strict) — compara 3 superficies de `policy.yaml` (`skills_allowed`, `lifecycle.*.hooks_required`, `audit.required_logs`) vs `.claude/logs/`; `skills_allowed` 13→14 | ✅ | — (PR pendiente) | | `feat/f2-agents-subagents` | 2 plugin subagents en `agents/` con namespace `pos-*`: `pos-code-reviewer` (consumido por `pre-commit-review`), `pos-architect` (consumido por `compound`); `auditor` diferido (sin consumer real); `agents_allowed` diferido (sin enforcement consumer) | ✅ | — (PR pendiente) | | `feat/f3-selftest-end-to-end` | `bin/pos-selftest.sh` + orquestador Python + 5 escenarios funcionales-críticos (D1/D3/D4/D5/D6) sobre proyecto sintético | ✅ | #27 | -| `refactor/template-policy-d5b-migration` | Migrar `templates/policy.yaml.hbs` + renderer + snapshots al shape post-D5b; cerrar drift documentado en D5b/F3 | ⏳ | — | +| `refactor/template-policy-d5b-migration` | Migrar `templates/policy.yaml.hbs` al shape contractual con loader (`enforced_patterns: []`, `skills_allowed` omitido, `pre_compact.persist` 3 items, `post_merge.trigger` genérico); cierra drift D5b/F3; contract test Python-side; overlays D4/D5 removidos | ✅ | — | | `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) | ⏳ | — | | `feat/g1-knowledge-plane-contract` | Contrato tool-agnostic (raw/wiki/schema) + opt-in questionnaire | ⏳ | — | @@ -739,10 +739,46 @@ Contrato fijado por la suite (extiende E1..F3 sin reabrirlos): - `/pos:pr-description` y `/pos:release` skills — diferidas por regla #7 CLAUDE.md (≥2 repeticiones documentadas). F4 entrega flow manual; cuando se observe el patrón ≥2 veces, extraer skill. - `CHANGELOG.md` enforced — F4 usa `--generate-notes` de `gh release create` (autogenerado de commits + PRs). Reabrir si el patrón sale corto. - Repo público `javiAI/pos-marketplace` — creación manual cuando se decida ir live. Activación por-runbook `docs/RELEASE.md § Mirror al marketplace público` (3 pasos: crear repo + `gh variable set` + `gh secret set`). -- `refactor/template-policy-d5b-migration` — rama propia post-F4 para migrar `templates/policy.yaml.hbs` al shape post-D5b (drift abierto desde F3). +- `refactor/template-policy-d5b-migration` — ✅ cerrada post-F4. Migra `templates/policy.yaml.hbs` al shape contractual con el loader (`pre_write.enforced_patterns: []`, `pre_pr.docs_sync_conditional: []`, `pre_compact.persist` con 3 items, `post_merge.skills_conditional[0].trigger` con globs genéricos conservadores, `skills_allowed` omitido por diseño). Contract test Python-side (`bin/tests/test_template_loader_contract.py`) corre los accessors reales del loader contra output del generator real sobre los 3 profiles canónicos. Overlays de D4 y D5 removidos en `bin/_selftest.py`; D3 y D6 mantienen overlays mínimos por diseño explícito (A1 emite lista vacía, A2 omite la clave). Suite post: 671 pytest + 515 vitest + selftest 5/5. **Criterio de salida**: 665 verdes + 1 skip. Sin regresión sobre F3. Docs-sync dentro del PR (ROADMAP § F4 + HANDOFF §1/§9/§22 + MASTER_PLAN § Rama F4 expandida + `.claude/rules/ci-cd.md` release job promovido + `docs/ARCHITECTURE.md § 13 Marketplace + Release flow` reescrita + `docs/RELEASE.md` nuevo runbook). `pre-pr-gate.py` aprueba este mismo PR — required `ROADMAP.md` + `HANDOFF.md` satisfecho; conditional `.github/**` no está bajo `generator|hooks|skills|patterns` (no requirement adicional). +### `refactor/template-policy-d5b-migration` — ✅ (PR pendiente) + +Sub-rama refactor post-F4 que cierra el drift `meta-repo ↔ template` documentado desde D5b y reforzado en F3 (cada escenario de selftest sobre-escribía `synthetic/policy.yaml` para evadir el shape pre-D5b emitido por el generator). Scope literal: el `policy.yaml` que emite el generator parsea limpio con el loader actual `hooks/_lib/policy.py` sin devolver `None` en los 5 accessors loader-relevant. + +Decisiones ratificadas en Fase -1 (A1-A6): + +- **A1**: `lifecycle.pre_write.enforced_patterns: []` — clave presente, lista vacía. Loader devuelve `PreWriteRules(())`, no `None`. Proyectos generados **no** heredan los enforcement patterns del meta-repo. +- **A2**: `skills_allowed` — clave **omitida**. Loader devuelve `None` (deferred), reflejando el estado del meta-repo hasta que el proyecto pueble la allowlist. +- **A3**: `lifecycle.pre_compact.persist` — los 3 items canónicos (`decisions_in_flight`, `phase_minus_one_state`, `unsaved_pattern_candidates`). +- **A4**: `lifecycle.post_merge.skills_conditional[0].trigger` — globs genéricos conservadores (`src/**`, `lib/**`, `*.py`, `package.json`, `pyproject.toml`) + `skip_if_only` para docs (`docs/**`, `*.md`, `.claude/patterns/**`) + `min_files_changed: 2`. Suficiente para que `post_merge_trigger()` devuelva un dataclass tipado; los proyectos afinan cuando sus convenciones estabilicen. +- **A5**: 2 commits — RED contract tests (`bin/tests/test_template_loader_contract.py`) → GREEN template + 3 snapshots regenerados + cleanup de overlays D4/D5 en `bin/_selftest.py`. +- **A6**: contract test Python-side. No re-implementación del contrato en TS; los accessors reales del loader corren contra output del generator real sobre los 3 profiles canónicos (cli-tool, nextjs-app, agent-sdk). + +Entregables: + +- `templates/policy.yaml.hbs` — 4 secciones añadidas/modificadas según A1/A3/A4 + A2 explícitamente omitida. +26 líneas vs pre-D5b. +- `generator/__snapshots__/{cli-tool,nextjs-app,agent-sdk}/policy.yaml.snap` — regenerados (cada uno +26 líneas espejando los nuevos bloques del template). +- `bin/_selftest.py` — overlays D4 (`POLICY_DOCS_SYNC_ONLY`) y D5 (`POLICY_POST_MERGE_ONLY`) removidos; D5 refactorizado para commitear `src/feature.py` + `src/helper.py` (matchea trigger genérico + `min_files_changed: 2`); D3 y D6 conservan overlays mínimos por diseño con comentario actualizado. +- `bin/tests/test_template_loader_contract.py` (NEW, 9 test cases × 3 profiles canónicos = 27 parametrizados) — 5 clases (`TestPreWriteRules`, `TestDocsSyncRules`, `TestPostMergeTrigger`, `TestPreCompactRules`, `TestSkillsAllowed`); fixture `module-scoped` para amortizar el coste de `npx tsx generator/run.ts`. + +Suite post-cierre: **671 passed + 1 skipped** (vs main baseline 644 + 1 skip = neto +27 nuevos contract tests, sin regresión D1..D6 / E1..E3 / F1..F4). Vitest 515 pass / 0 fail. Selftest end-to-end 5/5 escenarios pass sin overlays para D4/D5. + +Contrato fijado por la suite: + +- Cualquier modificación futura de `templates/policy.yaml.hbs` que rompa los 5 accessors del loader (`pre_write_rules`, `docs_sync_rules`, `post_merge_trigger`, `pre_compact_rules`, `skills_allowed_list`) cae en CI antes del merge. +- A2 lockdown: `skills_allowed` permanece **omitido** del template — `test_skills_allowed_list_is_none` falla si alguien añade la clave (incluso como `[]`). +- A1 lockdown: `enforced_patterns` permanece **presente y vacío** — `test_enforced_patterns_is_empty_tuple` falla si alguien copia patrones del meta-repo o elimina la clave. + +**Ajustes durante Fase -1 (3 ratificados por el usuario)**: + +- **No crear `templates/requirements-dev.txt.hbs`**: el contract test no demostró necesidad real (el loader corre con `pyyaml` ya instalado en el meta-repo durante el test; los proyectos generados materializarán su propio manifest cuando D5b/F4 evolucione el harness Python). +- **Framing literal**: scope descrito como "policy.yaml generado por template parsea con el loader actual" — **no** como "7 hooks blackbox" (overselling de implicaciones downstream que no eran parte de este PR). +- **post_merge trigger**: globs genéricos suficientes para remover overlays F3 D4+D5, **no** un diseño perfecto de triggers para cualquier proyecto futuro. Los proyectos afinan cuando sus convenciones estabilicen. + +**Criterio de salida**: 671 + 1 skip + selftest 5/5. Docs-sync dentro del PR (ROADMAP fila + § progreso + carry-over post-F4 flippeado a ✅; HANDOFF §1 + §9; MASTER_PLAN § F3b stub flippeado a cerrada; `docs/ARCHITECTURE.md § 10 Selftest end-to-end` drift cerrado + § 13 deferral flippeado; `.claude/rules/hooks.md` § Drift cerrado). + ## Convenciones de este archivo - Una fila por rama. `⏳` pendiente, `🔄` en vuelo, `✅` completada, `❌` abandonada, `🚫` bloqueada. diff --git a/bin/_selftest.py b/bin/_selftest.py index 3c27a33..88044e6 100755 --- a/bin/_selftest.py +++ b/bin/_selftest.py @@ -140,8 +140,9 @@ def scenario_d1_pre_branch_gate(synthetic: Path) -> tuple[bool, str]: def scenario_d3_pre_write_guard(synthetic: Path) -> tuple[bool, str]: """D3: deny Write to enforced path without test pair, allow with.""" - # Synthetic project's rendered policy lacks pre_write (template drift - # documented post-D5b). Inject a minimal policy so the hook enforces. + # The template (per A1) emits enforced_patterns: [] so generated projects + # do not inherit meta-repo enforcement. To exercise D3's deny path we + # inject a minimal policy with an explicit pattern. (synthetic / "policy.yaml").write_text(POLICY_PRE_WRITE_ONLY, encoding="utf-8") target = synthetic / "hooks" / "foo.py" @@ -161,19 +162,10 @@ def scenario_d3_pre_write_guard(synthetic: Path) -> tuple[bool, str]: return check_allow("allow phase", invoke_hook("pre-write-guard", payload, synthetic)) -POLICY_DOCS_SYNC_ONLY = textwrap.dedent("""\ - lifecycle: - pre_pr: - docs_sync_required: - - "ROADMAP.md" - - "HANDOFF.md" - docs_sync_conditional: [] -""") - - def scenario_d4_pre_pr_gate(synthetic: Path) -> tuple[bool, str]: """D4: deny `gh pr create` when docs-sync incomplete; allow when satisfied.""" - (synthetic / "policy.yaml").write_text(POLICY_DOCS_SYNC_ONLY, encoding="utf-8") + # Uses the policy.yaml emitted by the template (no overlay): baseline + # docs_sync_required = [ROADMAP.md, HANDOFF.md], docs_sync_conditional = []. init_baseline_repo(synthetic) git_in(synthetic, "checkout", "-q", "-b", "feat/example") (synthetic / "src.txt").write_text("payload\n", encoding="utf-8") @@ -200,29 +192,19 @@ def scenario_d4_pre_pr_gate(synthetic: Path) -> tuple[bool, str]: return check_allow("allow phase", invoke_hook("pre-pr-gate", payload, synthetic)) -POLICY_POST_MERGE_ONLY = textwrap.dedent("""\ - lifecycle: - post_merge: - skills_conditional: - - trigger: - touched_paths_any_of: - - "generator/*.ts" - skip_if_only: - - "*.md" - min_files_changed: 1 -""") - - def scenario_d5_post_action(synthetic: Path) -> tuple[bool, str]: """D5: confirmed merge whose diff matches trigger emits /pos:compound advisory.""" - (synthetic / "policy.yaml").write_text(POLICY_POST_MERGE_ONLY, encoding="utf-8") + # Uses the policy.yaml emitted by the template (no overlay): the generic + # post_merge trigger covers `src/**` with min_files_changed: 2. Two files + # under src/ satisfy both conditions. init_baseline_repo(synthetic) git_in(synthetic, "checkout", "-q", "-b", "feat/example") - target = synthetic / "generator" / "feature.ts" - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text("export const x = 1;\n", encoding="utf-8") - git_in(synthetic, "add", "generator/feature.ts") - git_in(synthetic, "commit", "-q", "-m", "feat: add generator/feature.ts") + src_dir = synthetic / "src" + src_dir.mkdir(parents=True, exist_ok=True) + (src_dir / "feature.py").write_text("# feature\n", encoding="utf-8") + (src_dir / "helper.py").write_text("# helper\n", encoding="utf-8") + git_in(synthetic, "add", "src/feature.py", "src/helper.py") + git_in(synthetic, "commit", "-q", "-m", "feat: add src/feature.py + src/helper.py") git_in(synthetic, "checkout", "-q", "main") git_in(synthetic, "merge", "--no-ff", "feat/example", "-m", "Merge feat/example") @@ -251,6 +233,9 @@ def scenario_d5_post_action(synthetic: Path) -> tuple[bool, str]: def scenario_d6_stop_policy_check(synthetic: Path) -> tuple[bool, str]: """D6: enforce skill allowlist scoped by session_id; allow when clean.""" + # The template (per A2) omits the skills_allowed key so the hook degrades + # to status: deferred. To exercise D6's deny path we inject a minimal + # policy with an explicit allowlist. (synthetic / "policy.yaml").write_text(POLICY_SKILLS_ALLOWED_ONLY, encoding="utf-8") skills_log = synthetic / ".claude" / "logs" / "skills.jsonl" skills_log.parent.mkdir(parents=True, exist_ok=True) diff --git a/bin/tests/test_template_loader_contract.py b/bin/tests/test_template_loader_contract.py new file mode 100644 index 0000000..2587660 --- /dev/null +++ b/bin/tests/test_template_loader_contract.py @@ -0,0 +1,182 @@ +"""RED contract — generated policy.yaml parses cleanly with hooks/_lib/policy.py. + +Drift documented since D5b: templates/policy.yaml.hbs emits the pre-D5b shape, +so the real loader returns None for several loader-relevant accessors when run +against a project generated by `pos`. F3 selftest evades this with per-scenario +policy.yaml overlays in bin/_selftest.py. This branch closes that drift by +migrating templates/policy.yaml.hbs to the shape the loader expects today. + +Contract per ratified Fase -1 decisions (A1-A6): + +- A1: lifecycle.pre_write.enforced_patterns is emitted as []. The key is + present, the list is empty. Loader returns PreWriteRules(()), not None. + Generated projects do not inherit meta-repo enforcement patterns. +- A2: skills_allowed key is omitted from generated policy.yaml. + Loader returns None (deferred), matching meta-repo state until skills + become populated. +- A3: lifecycle.pre_compact.persist contains the three canonical items + (decisions_in_flight, phase_minus_one_state, unsaved_pattern_candidates). +- A4: lifecycle.post_merge.skills_conditional[0].trigger is present with + conservative generic globs (src/**, lib/**, *.py, package.json, + pyproject.toml). Not a perfect trigger for every project — sufficient + to make post_merge_trigger() return a typed dataclass instead of None. +- A6: this test runs Python-side against the real loader and the real + generator output. No TS-side reimplementation of the contract. + +Fails until templates/policy.yaml.hbs is migrated. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +GENERATOR = REPO_ROOT / "generator" / "run.ts" +PROFILES_DIR = REPO_ROOT / "questionnaire" / "profiles" +HOOKS_DIR = REPO_ROOT / "hooks" + +sys.path.insert(0, str(HOOKS_DIR)) +from _lib.policy import ( # noqa: E402 + DocsSyncRules, + PostMergeTrigger, + PreCompactRules, + PreWriteRules, + docs_sync_rules, + post_merge_trigger, + pre_compact_rules, + pre_write_rules, + reset_cache, + skills_allowed_list, +) + +CANONICAL_PROFILES = ("cli-tool", "nextjs-app", "agent-sdk") + + +def _generate(profile_slug: str, target: Path) -> None: + profile = PROFILES_DIR / f"{profile_slug}.yaml" + subprocess.run( + [ + "npx", "tsx", str(GENERATOR), + "--profile", str(profile), + "--out", str(target), + ], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + timeout=120, + ) + + +@pytest.fixture(scope="module", params=CANONICAL_PROFILES) +def synthetic(request: pytest.FixtureRequest) -> Path: + """Generate one synthetic project per canonical profile (module-scoped). + + Module scope keeps the heavy `npx tsx` invocation to one per profile + instead of one per test method. + """ + tmp = Path(tempfile.mkdtemp(prefix=f"pos-tmpl-contract-{request.param}-")) + target = tmp / "synthetic" + try: + _generate(request.param, target) + reset_cache() + yield target + finally: + reset_cache() + shutil.rmtree(tmp, ignore_errors=True) + + +class TestPreWriteRules: + """A1 — enforced_patterns emitted as empty list, not absent.""" + + def test_returns_dataclass_not_none(self, synthetic: Path) -> None: + result = pre_write_rules(synthetic) + assert result is not None, ( + "pre_write_rules() returned None — generated policy.yaml lacks " + "lifecycle.pre_write.enforced_patterns. A1 requires the key to be " + "present with an empty list." + ) + assert isinstance(result, PreWriteRules) + + def test_enforced_patterns_is_empty_tuple(self, synthetic: Path) -> None: + result = pre_write_rules(synthetic) + assert result is not None + assert result.enforced_patterns == () + + +class TestDocsSyncRules: + """A1 (implicit) — docs_sync_conditional emitted, even if empty list.""" + + def test_returns_dataclass_not_none(self, synthetic: Path) -> None: + result = docs_sync_rules(synthetic) + assert result is not None, ( + "docs_sync_rules() returned None — generated policy.yaml lacks " + "lifecycle.pre_pr.docs_sync_conditional (loader requires the key " + "to be a list, even if empty)." + ) + assert isinstance(result, DocsSyncRules) + + def test_baseline_includes_roadmap_and_handoff(self, synthetic: Path) -> None: + result = docs_sync_rules(synthetic) + assert result is not None + assert "ROADMAP.md" in result.baseline + assert "HANDOFF.md" in result.baseline + + +class TestPostMergeTrigger: + """A4 — conservative generic trigger present, dataclass populated.""" + + def test_returns_dataclass_not_none(self, synthetic: Path) -> None: + result = post_merge_trigger(synthetic) + assert result is not None, ( + "post_merge_trigger() returned None — generated policy.yaml lacks " + "lifecycle.post_merge.skills_conditional[0].trigger. A4 requires " + "a generic stack-conditional trigger." + ) + assert isinstance(result, PostMergeTrigger) + + def test_min_files_changed_is_int(self, synthetic: Path) -> None: + result = post_merge_trigger(synthetic) + assert result is not None + assert isinstance(result.min_files_changed, int) + assert not isinstance(result.min_files_changed, bool) + + +class TestPreCompactRules: + """A3 — persist list contains all three canonical items.""" + + def test_returns_dataclass_not_none(self, synthetic: Path) -> None: + result = pre_compact_rules(synthetic) + assert result is not None, ( + "pre_compact_rules() returned None — generated policy.yaml lacks " + "lifecycle.pre_compact.persist." + ) + assert isinstance(result, PreCompactRules) + + def test_persist_has_three_canonical_items(self, synthetic: Path) -> None: + result = pre_compact_rules(synthetic) + assert result is not None + assert "decisions_in_flight" in result.persist + assert "phase_minus_one_state" in result.persist + assert "unsaved_pattern_candidates" in result.persist, ( + "A3: pre_compact.persist must include unsaved_pattern_candidates " + "(third item, missing from pre-D5b template)." + ) + + +class TestSkillsAllowed: + """A2 — key omitted; loader returns None (deferred), not empty tuple.""" + + def test_skills_allowed_list_is_none(self, synthetic: Path) -> None: + result = skills_allowed_list(synthetic) + assert result is None, ( + f"skills_allowed_list() returned {result!r}; A2 requires the " + f"skills_allowed key to be OMITTED in generated policy.yaml so " + f"the loader returns None (deferred)." + ) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d576461..d359754 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -615,7 +615,7 @@ Mismo set que local + matriz (2 OS × 2 versiones runtime). Branch protection re - **Orquestador** `bin/_selftest.py` (stdlib only, sin deps externas). Por escenario: 1. crea tmpdir, 2. ejecuta `npx tsx generator/run.ts --profile questionnaire/profiles/cli-tool.yaml --out ` (la generator real, no fixture committeado), - 3. sobre-escribe la sección mínima de `synthetic/policy.yaml` que el escenario necesita (desacopla la cobertura de la migración pendiente del template `policy.yaml.hbs` al shape post-D5b), + 3. sobre-escribe la sección mínima de `synthetic/policy.yaml` sólo cuando el escenario necesita un setup distinto del baseline emitido por el template (D3 inyecta un `enforced_patterns` no vacío sobre la lista vacía del template; D6 inyecta una `skills_allowed` sobre la clave omitida; D4 y D5 corren contra el baseline tras el cierre de drift en `refactor/template-policy-d5b-migration`), 4. monta el sintético como git repo (`git init -b main` + commit baseline), 5. invoca el hook real (`hooks/.py`) vía subprocess con payload JSON, 6. asserta exit code + presencia de tokens en stdout/stderr/files, @@ -626,7 +626,7 @@ Mismo set que local + matriz (2 OS × 2 versiones runtime). Branch protection re **CI**: nuevo job `selftest` en `.github/workflows/ci.yml` (ubuntu × Python 3.11, single matrix — gates funcionales platform-agnostic; matriz extendida sería sobre-promesa). Comando único: `pytest bin/tests -q`. Ejecución end-to-end local ~1.2s. -**Drift abierto**: `templates/policy.yaml.hbs` y `generator/renderers/policy.ts` siguen emitiendo el shape pre-D5b. F3 lo evade sobre-escribiendo la sección relevante en `synthetic/policy.yaml` por escenario. Reabrir migración del template en rama propia post-F3. +**Drift cerrado** (`refactor/template-policy-d5b-migration`, post-F4): `templates/policy.yaml.hbs` migrado al shape contractual con el loader (`pre_write.enforced_patterns: []`, `pre_pr.docs_sync_conditional: []`, `pre_compact.persist` con los 3 items canónicos, `post_merge.skills_conditional[0].trigger` con globs genéricos conservadores, `skills_allowed` omitido por diseño). Contrato lockdown vía `bin/tests/test_template_loader_contract.py` corriendo los accessors reales del loader contra el output del generador real sobre los 3 profiles canónicos. `bin/_selftest.py` removió los overlays de D4 y D5; D3 y D6 mantienen overlays mínimos por diseño (A1 emite lista vacía, A2 omite la clave). ### Generador emite test harness @@ -705,7 +705,7 @@ Plugin `pos` se publica vía: - `audit.yml` nightly (declarado en `policy.yaml.ci_cd.workflows` desde Fase A; sin consumer activo). - Skills `/pos:pr-description` y `/pos:release` (sin repetición demostrada del flow manual). - `CHANGELOG.md` enforced (autogenerated notes de `gh release create` cubren hasta nueva señal). -- Migración del `templates/policy.yaml.hbs` (drift abierto post-D5b en `refactor/template-policy-d5b-migration`; ortogonal a F4). +- Migración del `templates/policy.yaml.hbs` — cerrada en `refactor/template-policy-d5b-migration` post-F4 (era ortogonal a F4). ## 14. Anti-legacy / evolución diff --git a/generator/__snapshots__/agent-sdk/policy.yaml.snap b/generator/__snapshots__/agent-sdk/policy.yaml.snap index 199677c..3d96f5c 100644 --- a/generator/__snapshots__/agent-sdk/policy.yaml.snap +++ b/generator/__snapshots__/agent-sdk/policy.yaml.snap @@ -60,6 +60,12 @@ lifecycle: skills_required: ["pos:pre-commit-review"] blocking: true + pre_write: + # Empty by default. Generated projects do not inherit meta-repo enforcement + # patterns; declare project-specific entries here when conventions stabilize. + # Loader contract: hooks/_lib/policy.py::pre_write_rules → PreWriteRules(()). + enforced_patterns: [] + pre_push: checks_required: - name: "typecheck" @@ -80,11 +86,30 @@ lifecycle: docs_sync_required: - "ROADMAP.md" - "HANDOFF.md" + docs_sync_conditional: [] ci_dry_run_required: true invariants_check: true blocking: true post_merge: + # Generic post-merge trigger; projects should tune this once conventions + # stabilize. The globs below cover common TS/JS + Python source layouts + # without assuming meta-repo paths. Loader contract: + # hooks/_lib/policy.py::post_merge_trigger → PostMergeTrigger. + skills_conditional: + - skill: "pos:compound" + trigger: + touched_paths_any_of: + - "src/**" + - "lib/**" + - "*.py" + - "package.json" + - "pyproject.toml" + skip_if_only: + - "docs/**" + - "*.md" + - ".claude/patterns/**" + min_files_changed: 2 hooks_required: ["post-action.py"] pre_compact: @@ -92,6 +117,7 @@ lifecycle: persist: - decisions_in_flight - phase_minus_one_state + - unsaved_pattern_candidates # ───────────────────────────────────────────── # Safety — community tools diff --git a/generator/__snapshots__/cli-tool/policy.yaml.snap b/generator/__snapshots__/cli-tool/policy.yaml.snap index 6da4543..edd2d1d 100644 --- a/generator/__snapshots__/cli-tool/policy.yaml.snap +++ b/generator/__snapshots__/cli-tool/policy.yaml.snap @@ -60,6 +60,12 @@ lifecycle: skills_required: ["pos:pre-commit-review"] blocking: true + pre_write: + # Empty by default. Generated projects do not inherit meta-repo enforcement + # patterns; declare project-specific entries here when conventions stabilize. + # Loader contract: hooks/_lib/policy.py::pre_write_rules → PreWriteRules(()). + enforced_patterns: [] + pre_push: checks_required: - name: "typecheck" @@ -80,11 +86,30 @@ lifecycle: docs_sync_required: - "ROADMAP.md" - "HANDOFF.md" + docs_sync_conditional: [] ci_dry_run_required: true invariants_check: true blocking: true post_merge: + # Generic post-merge trigger; projects should tune this once conventions + # stabilize. The globs below cover common TS/JS + Python source layouts + # without assuming meta-repo paths. Loader contract: + # hooks/_lib/policy.py::post_merge_trigger → PostMergeTrigger. + skills_conditional: + - skill: "pos:compound" + trigger: + touched_paths_any_of: + - "src/**" + - "lib/**" + - "*.py" + - "package.json" + - "pyproject.toml" + skip_if_only: + - "docs/**" + - "*.md" + - ".claude/patterns/**" + min_files_changed: 2 hooks_required: ["post-action.py"] pre_compact: @@ -92,6 +117,7 @@ lifecycle: persist: - decisions_in_flight - phase_minus_one_state + - unsaved_pattern_candidates # ───────────────────────────────────────────── # Safety — community tools diff --git a/generator/__snapshots__/nextjs-app/policy.yaml.snap b/generator/__snapshots__/nextjs-app/policy.yaml.snap index 6da4543..edd2d1d 100644 --- a/generator/__snapshots__/nextjs-app/policy.yaml.snap +++ b/generator/__snapshots__/nextjs-app/policy.yaml.snap @@ -60,6 +60,12 @@ lifecycle: skills_required: ["pos:pre-commit-review"] blocking: true + pre_write: + # Empty by default. Generated projects do not inherit meta-repo enforcement + # patterns; declare project-specific entries here when conventions stabilize. + # Loader contract: hooks/_lib/policy.py::pre_write_rules → PreWriteRules(()). + enforced_patterns: [] + pre_push: checks_required: - name: "typecheck" @@ -80,11 +86,30 @@ lifecycle: docs_sync_required: - "ROADMAP.md" - "HANDOFF.md" + docs_sync_conditional: [] ci_dry_run_required: true invariants_check: true blocking: true post_merge: + # Generic post-merge trigger; projects should tune this once conventions + # stabilize. The globs below cover common TS/JS + Python source layouts + # without assuming meta-repo paths. Loader contract: + # hooks/_lib/policy.py::post_merge_trigger → PostMergeTrigger. + skills_conditional: + - skill: "pos:compound" + trigger: + touched_paths_any_of: + - "src/**" + - "lib/**" + - "*.py" + - "package.json" + - "pyproject.toml" + skip_if_only: + - "docs/**" + - "*.md" + - ".claude/patterns/**" + min_files_changed: 2 hooks_required: ["post-action.py"] pre_compact: @@ -92,6 +117,7 @@ lifecycle: persist: - decisions_in_flight - phase_minus_one_state + - unsaved_pattern_candidates # ───────────────────────────────────────────── # Safety — community tools diff --git a/templates/policy.yaml.hbs b/templates/policy.yaml.hbs index 7bfc47b..3274423 100644 --- a/templates/policy.yaml.hbs +++ b/templates/policy.yaml.hbs @@ -61,6 +61,12 @@ lifecycle: skills_required: ["pos:pre-commit-review"] blocking: true + pre_write: + # Empty by default. Generated projects do not inherit meta-repo enforcement + # patterns; declare project-specific entries here when conventions stabilize. + # Loader contract: hooks/_lib/policy.py::pre_write_rules → PreWriteRules(()). + enforced_patterns: [] + pre_push: checks_required: {{#if (eq answers.stack.language "typescript")}} @@ -91,11 +97,30 @@ lifecycle: docs_sync_required: - "ROADMAP.md" - "HANDOFF.md" + docs_sync_conditional: [] ci_dry_run_required: true invariants_check: true blocking: true post_merge: + # Generic post-merge trigger; projects should tune this once conventions + # stabilize. The globs below cover common TS/JS + Python source layouts + # without assuming meta-repo paths. Loader contract: + # hooks/_lib/policy.py::post_merge_trigger → PostMergeTrigger. + skills_conditional: + - skill: "pos:compound" + trigger: + touched_paths_any_of: + - "src/**" + - "lib/**" + - "*.py" + - "package.json" + - "pyproject.toml" + skip_if_only: + - "docs/**" + - "*.md" + - ".claude/patterns/**" + min_files_changed: 2 hooks_required: ["post-action.py"] pre_compact: @@ -103,6 +128,7 @@ lifecycle: persist: - decisions_in_flight - phase_minus_one_state + - unsaved_pattern_candidates # ───────────────────────────────────────────── # Safety — community tools