pumuki 6.3.36 → 6.3.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/VERSION +1 -1
  2. package/docs/CONFIGURATION.md +1 -1
  3. package/docs/README.md +1 -1
  4. package/docs/RELEASE_NOTES.md +45 -0
  5. package/docs/registro-maestro-de-seguimiento.md +7 -6
  6. package/docs/seguimiento-activo-pumuki-saas-supermercados.md +77 -0
  7. package/docs/seguimiento-completo-validacion-ruralgo-03-03-2026.md +53 -4
  8. package/integrations/evidence/schema.ts +1 -1
  9. package/integrations/gate/evaluateAiGate.ts +20 -2
  10. package/integrations/gate/stagePolicies.ts +17 -1
  11. package/integrations/git/GitService.ts +28 -1
  12. package/integrations/git/gitAtomicity.ts +274 -0
  13. package/integrations/git/runPlatformGate.ts +7 -0
  14. package/integrations/git/stageRunners.ts +193 -4
  15. package/integrations/lifecycle/adapter.templates.json +10 -10
  16. package/integrations/lifecycle/cli.ts +72 -2
  17. package/integrations/lifecycle/doctor.ts +14 -3
  18. package/integrations/lifecycle/hookBlock.ts +37 -11
  19. package/integrations/lifecycle/packageInfo.ts +27 -1
  20. package/integrations/lifecycle/policyValidationSnapshot.ts +51 -0
  21. package/integrations/lifecycle/status.ts +7 -1
  22. package/integrations/mcp/autoExecuteAiStart.ts +116 -0
  23. package/integrations/mcp/enterpriseServer.ts +56 -0
  24. package/integrations/mcp/preFlightCheck.ts +108 -0
  25. package/integrations/notifications/emitAuditSummaryNotification.ts +28 -0
  26. package/integrations/telemetry/gateTelemetry.ts +2 -2
  27. package/package.json +1 -1
  28. package/scripts/framework-menu-consumer-preflight-lib.ts +11 -0
  29. package/scripts/framework-menu-evidence-summary-lib.ts +1 -1
  30. package/scripts/framework-menu-system-notifications-lib.ts +281 -17
package/VERSION CHANGED
@@ -1 +1 @@
1
- v6.3.36
1
+ v6.3.38
@@ -229,7 +229,7 @@ Expected JSONL keys for enterprise audit ingestion:
229
229
  - `schema=telemetry_event_v1` with `schema_version=1.0`
230
230
  - `stage`, `gate_outcome`, `severity_counts`
231
231
  - `policy.bundle`, `policy.hash`, `policy.version`, `policy.signature`, `policy.policy_source`
232
- - `policy.validation_status`, `policy.validation_code` (when policy-as-code validation is available)
232
+ - `policy.validation_status`, `policy.validation_code` (when policy-as-code validation is available; status can be `valid|invalid|expired|unknown-source|unsigned`)
233
233
 
234
234
  ## Heuristic pilot flag
235
235
 
package/docs/README.md CHANGED
@@ -5,7 +5,7 @@ Canonical index for active Pumuki documentation.
5
5
  ## Seguimiento Activo (único)
6
6
 
7
7
  - Maestro: `docs/registro-maestro-de-seguimiento.md`
8
- - Plan activo: `docs/seguimiento-completo-validacion-ruralgo-03-03-2026.md`
8
+ - Plan activo: `docs/seguimiento-activo-pumuki-saas-supermercados.md`
9
9
  - Política: una sola tarea en construcción (`🚧`) en el plan activo.
10
10
 
11
11
  ## Product and Architecture
@@ -5,6 +5,51 @@ Detailed commit history remains available through Git history (`git log` / `git
5
5
 
6
6
  ## 2026-03 (enterprise hardening updates)
7
7
 
8
+ ### 2026-03-04 (v6.3.38)
9
+
10
+ - Blocked notification UX hardening for macOS:
11
+ - short, human-readable Spanish banner (`🔴 Pumuki bloqueado`) with stage + summarized cause.
12
+ - remediation-first body (`Solución: ...`) to maximize visibility in truncated notification banners.
13
+ - Optional blocked-dialog workflow (`PUMUKI_MACOS_BLOCKED_DIALOG=1`):
14
+ - full cause/remediation modal for critical blocks.
15
+ - anti-spam controls in dialog:
16
+ - `Mantener activas`
17
+ - `Silenciar 30 min`
18
+ - `Desactivar`
19
+ - `giving up after 15` timeout to avoid local execution hangs.
20
+ - Notification delivery contract update:
21
+ - config supports `muteUntil` in `.pumuki/system-notifications.json`.
22
+ - delivery result now reports `reason=muted` while silence window is active.
23
+ - Regression baseline alignment:
24
+ - `integrations/git/__tests__/stageRunners.test.ts` updated to run with core skills enabled in test harness, matching current gate contract and eliminating false failures.
25
+ - Traceability:
26
+ - commits: `2f957a2`, `ceb1849`, `98fc108`, `ae90f31`
27
+ - Consumer quick verification:
28
+ - `npx --yes tsx@4.21.0 --test scripts/__tests__/framework-menu-system-notifications.test.ts`
29
+ - `npx --yes tsx@4.21.0 --test integrations/git/__tests__/stageRunners.test.ts`
30
+ - `PUMUKI_MACOS_BLOCKED_DIALOG=1 npx --yes tsx@4.21.0 -e "import { emitSystemNotification } from './scripts/framework-menu-system-notifications-lib'; console.log(JSON.stringify(emitSystemNotification({ repoRoot: process.cwd(), event: { kind: 'gate.blocked', stage: 'PRE_COMMIT', totalViolations: 1, causeCode: 'BACKEND_AVOID_EXPLICIT_ANY', causeMessage: 'Avoid explicit any in backend code.', remediation: 'Tipa el valor y elimina any explícito en backend.' } })));"`
31
+ - expected signal:
32
+ - banner appears with concise remediation text,
33
+ - optional dialog appears with anti-spam actions when flag is enabled.
34
+
35
+ ### 2026-03-04 (v6.3.37)
36
+
37
+ - Policy-as-code enterprise hardening shipped:
38
+ - strict mode now blocks unsigned runtime policy metadata with deterministic code `POLICY_AS_CODE_UNSIGNED`.
39
+ - lifecycle outputs now expose policy validation metadata in `status --json`, `doctor --json`, and `sdd validate --json`.
40
+ - telemetry/evidence contract now supports policy validation status `unsigned`.
41
+ - Traceability:
42
+ - implementation issue: `#606`
43
+ - implementation PR: `#608`
44
+ - tracking sync PR: `#609`
45
+ - Consumer quick verification:
46
+ - `npx --yes --package pumuki@latest pumuki status --json`
47
+ - `npx --yes --package pumuki@latest pumuki doctor --json`
48
+ - `PUMUKI_POLICY_STRICT=1 npx --yes --package pumuki@latest pumuki-pre-commit`
49
+ - expected signal:
50
+ - JSON includes `policyValidation.stages.*.validationCode`.
51
+ - strict mode blocks unsigned contracts with `POLICY_AS_CODE_UNSIGNED`.
52
+
8
53
  ### 2026-03-04 (v6.3.36)
9
54
 
10
55
  - SDD orchestration hardening shipped:
@@ -5,14 +5,15 @@
5
5
  - Referenciar un único plan activo con fases, tasks y leyenda.
6
6
 
7
7
  ## Estado actual
8
- - Plan activo: `docs/seguimiento-completo-validacion-ruralgo-03-03-2026.md`
9
- - Estado del plan: EN CURSO
10
- - Última task cerrada (`✅`): `P12.F2.T70` (comando orquestador `pumuki sdd auto-sync`, issue `#600`, PR `#602`, commit `2be34c5`).
11
- - Task activa (`🚧`): `P12.F2.T71` (publicar release `6.3.36` con `auto-sync`, issue `#603`).
8
+ - Plan activo: `docs/seguimiento-activo-pumuki-saas-supermercados.md`
9
+ - Estado del plan: EJECUCION
10
+ - Última task cerrada (`✅`): Fase 3.2 (actualización de `CHANGELOG.md` + `docs/RELEASE_NOTES.md` con fixes reales).
11
+ - Task activa (`🚧`): Fase 3.3 publicación de versión.
12
+ - Nuevos pendientes añadidos (`⏳`): sin cambios.
12
13
 
13
14
  ## Historial resumido
14
- - No se mantienen MDs históricos de seguimiento en este repositorio.
15
- - La trazabilidad histórica relevante debe consolidarse dentro del plan activo o en documentación oficial no temporal.
15
+ - Bloque RuralGO cerrado: `docs/seguimiento-completo-validacion-ruralgo-03-03-2026.md`.
16
+ - Se inicia bloque SAAS_SUPERMERCADOS con plan activo único y legible.
16
17
 
17
18
  ## Regla de operación
18
19
  - Debe existir exactamente una task `🚧` en el plan activo.
@@ -0,0 +1,77 @@
1
+ # Plan Activo Pumuki SAAS Supermercados
2
+
3
+ ## Leyenda
4
+
5
+ - ✅ Cerrado
6
+ - 🚧 En construccion (maximo 1)
7
+ - ⏳ Pendiente
8
+ - ⛔ Bloqueado
9
+
10
+ ## Objetivo
11
+
12
+ - Resolver e implementar bugs y mejoras reportados en:
13
+ - `/Users/juancarlosmerlosalbarracin/Developer/Projects/SAAS:APP_SUPERMERCADOS/docs/pumuki/PUMUKI_BUGS_MEJORAS.md`
14
+ - Mantener trazabilidad: hallazgo -> fix -> test -> release notes.
15
+
16
+ ## Fase 0. Intake y priorizacion
17
+
18
+ - ✅ Consolidar hallazgos y deduplicar causas raiz del MD canónico.
19
+ - ✅ Priorizar por impacto inicial:
20
+ - P1: `PUMUKI-001`, `PUMUKI-003`, `PUMUKI-005`, `PUMUKI-007`
21
+ - P2: `PUMUKI-002`, `PUMUKI-004`, `PUMUKI-006`
22
+
23
+ ## Fase 1. Bugs P1 (ejecucion tecnica)
24
+
25
+ - ✅ PUMUKI-001: Compatibilidad de receipt MCP entre stages (`PRE_WRITE` vs `PRE_COMMIT`) sin bloqueo falso.
26
+ - ✅ PUMUKI-003: Endurecer resolución de binarios en hooks/scripts para evitar `command not found`.
27
+ - ✅ PUMUKI-005: Soporte robusto para repos con `:` en path (evitar dependencia frágil de PATH).
28
+ - ✅ PUMUKI-007: Soportar repos sin commits (`HEAD` ausente) sin error ambiguo.
29
+
30
+ ## Fase 2. Mejoras P2
31
+
32
+ - ✅ PUMUKI-002: Rule-pack opcional de atomicidad Git + trazabilidad de commit message.
33
+ - ✅ PUMUKI-004: Mejorar diagnóstico de hooks efectivos en escenarios versionados/custom.
34
+ - ✅ PUMUKI-006: Alinear `package_version` reportada por MCP con versión local efectiva del repo consumidor.
35
+
36
+ ## Fase 2.1 Paridad legacy (CLI vs MCP) en SAAS_SUPERMERCADOS
37
+
38
+ - ✅ PUMUKI-008: Feedback iterativo en chat no equivalente a flujo legacy.
39
+ - Evidencia: en ejecución MCP no aparece feedback operativo por iteración del modelo como en el grafo legacy.
40
+ - Esperado: resumen corto y humano en cada iteración (`stage`, `decision`, `next_action`).
41
+ - Entregable: tool MCP de pre-flight para chat con salida estable y accionable.
42
+ - ✅ PUMUKI-009: Desalineación operativa entre `ai_gate_check` y `pre_flight_check`.
43
+ - Evidencia: `ai_gate_check => BLOCKED (EVIDENCE_STALE)` mientras `pre_flight_check => allowed=true`.
44
+ - Esperado: criterio homogéneo o explicación explícita y trazable de por qué uno bloquea y el otro permite.
45
+ - Entregable: decisión unificada desde el mismo evaluador/política.
46
+ - ✅ PUMUKI-010: Respuesta no accionable en `auto_execute_ai_start` para confianza media.
47
+ - Evidencia: `success=true`, `action=ask`, `message=Medium confidence (undefined%)...`.
48
+ - Esperado: `next_action` determinista + confidence numérico consistente + remediación concreta.
49
+ - Entregable: contrato MCP estable (`confidence_pct`, `reason_code`, `next_action`).
50
+ - ✅ PUMUKI-011: Notificación macOS obligatoria en cualquier bloqueo de gate/fase.
51
+ - Requisito hard: cuando Pumuki bloquee (`PRE_WRITE`, `PRE_COMMIT`, `PRE_PUSH`, `CI`), lanzar notificación nativa macOS con sonido.
52
+ - Contenido mínimo: `🔴 BLOQUEADO`, causa exacta (`code + message`) y `cómo solucionarlo` (`next_action`).
53
+ - Entregable: comportamiento consistente en CLI, hooks y herramientas MCP con formato humano.
54
+ - Ajuste UX (2026-03-04): mensaje corto y legible para humanos.
55
+ - Nuevo formato: subtítulo con causa resumida + cuerpo iniciando por `Solución: ...` para que no se corte la remediación.
56
+ - ✅ PoC (2026-03-04): modo opcional de diálogo completo para bloqueo en macOS con `PUMUKI_MACOS_BLOCKED_DIALOG=1` (banner corto + modal con causa/solución completas).
57
+ - ✅ PoC anti-spam (2026-03-04): diálogo con acciones de control (`Mantener activas`, `Silenciar 30 min`, `Desactivar`) + timeout automático de 15s para no bloquear flujo.
58
+
59
+ ## Decisión de producto (hard)
60
+
61
+ - La automatización es obligatoria: minimizar pasos manuales en bootstrap, pre-flight, gate y remediación.
62
+ - Objetivo operativo: que instalación + wiring de agente dejen el flujo listo para ejecutar y reportar bloqueos sin intervención manual extra.
63
+
64
+ ## Aclaración operativa (para no perderse)
65
+
66
+ - CLI: lo ejecutan hooks Git (`pre-commit`, `pre-push`) y comandos manuales (`pumuki ...`).
67
+ - MCP: lo consume el agente/herramienta (Codex/Cursor/Windsurf...) cuando está configurado.
68
+ - `pumuki install`: instala hooks Git y bootstrap base.
69
+ - `pumuki adapter install --agent=<...>`: cablea hooks de agente + servidores MCP en el entorno del agente.
70
+
71
+ ## Fase 3. Cierre
72
+
73
+ - ✅ Ejecutar suite de tests de regresión afectada.
74
+ - Evidencia (2026-03-04): `npx --yes tsx@4.21.0 --test scripts/__tests__/framework-menu-system-notifications.test.ts integrations/git/__tests__/stageRunners.test.ts integrations/lifecycle/__tests__/lifecycle.test.ts` -> `44 pass / 0 fail`.
75
+ - ✅ Actualizar `CHANGELOG.md` y `docs/RELEASE_NOTES.md` con fixes reales.
76
+ - Evidencia (2026-03-04): se documenta en `Unreleased` (CHANGELOG) y en `next patch candidate` (RELEASE_NOTES) el paquete de mejoras `PUMUKI-011` + baseline test alignment.
77
+ - 🚧 Publicar versión cuando las tareas en construcción/pending críticas estén cerradas.
@@ -1968,11 +1968,60 @@ Criterio de salida F5:
1968
1968
  - `npx --yes tsx@4.21.0 --test integrations/sdd/__tests__/syncDocs.test.ts integrations/sdd/__tests__/index.test.ts integrations/lifecycle/__tests__/cli.test.ts` => `44 passed, 0 failed`.
1969
1969
  - `npm run -s typecheck` => `exit 0`.
1970
1970
 
1971
- - 🚧 `P12.F2.T71` Publicar release `6.3.36` con `pumuki sdd auto-sync` (`#603`).
1972
- - salida esperada:
1971
+ - `P12.F2.T71` Publicar release `6.3.36` con `pumuki sdd auto-sync` (`#603`).
1972
+ - cierre ejecutado:
1973
1973
  - versionado a `6.3.36` en `package.json`, `package-lock.json` y `VERSION`.
1974
- - release notes actualizadas con entrada `v6.3.36`.
1975
- - publicación npm validada (`latest=6.3.36`) + smoke de `--help` mostrando `auto-sync`.
1974
+ - release notes actualizadas con entrada `2026-03-04 (v6.3.36)` en `docs/RELEASE_NOTES.md`.
1975
+ - publicación npm ejecutada con éxito (`npm publish --access public`).
1976
+ - propagación validada:
1977
+ - `npm view pumuki dist-tags --json` => `"latest": "6.3.36"`.
1978
+ - smoke `@latest` con `--help` mostrando `pumuki sdd auto-sync ...`.
1979
+ - evidencia:
1980
+ - `npx --yes tsx@4.21.0 --test integrations/sdd/__tests__/syncDocs.test.ts integrations/sdd/__tests__/index.test.ts integrations/lifecycle/__tests__/cli.test.ts` => `44 passed, 0 failed`.
1981
+ - `npm run -s typecheck` => `exit 0`.
1982
+ - `npm publish --access public` => `+ pumuki@6.3.36`.
1983
+ - `npm view pumuki@6.3.36 version` => `6.3.36`.
1984
+
1985
+ - ✅ `P12.F2.T72` Hardening enterprise policy-as-code firmada/versionada (`#606`).
1986
+ - cierre ejecutado:
1987
+ - modo estricto bloquea política no firmada con código determinista `POLICY_AS_CODE_UNSIGNED`.
1988
+ - `status`, `doctor` y `sdd validate --json` exponen metadatos de validación de policy (`source/bundle/hash/version/signature/status/code/strict`).
1989
+ - telemetría/evidence alineadas con estado adicional `unsigned`.
1990
+ - cobertura de regresión añadida para strict unsigned + metadatos lifecycle.
1991
+ - issue cerrada: `#606`.
1992
+ - PR mergeada: `#608` (`commit 881eac8`).
1993
+ - evidencia:
1994
+ - `npx --yes tsx@4.21.0 --test integrations/gate/__tests__/stagePolicies.test.ts integrations/git/__tests__/runPlatformGate.test.ts integrations/lifecycle/__tests__/status.test.ts integrations/lifecycle/__tests__/doctor.test.ts integrations/lifecycle/__tests__/cli.test.ts` => `81 passed, 0 failed`.
1995
+ - `npm run -s typecheck` => `exit 0`.
1996
+
1997
+ - ✅ `P12.F2.T73` Preparar y publicar release con el hardening de `#606`.
1998
+ - cierre ejecutado:
1999
+ - release branch creada: `release/6.3.37`.
2000
+ - versionado aplicado en `package.json`, `package-lock.json`, `VERSION` y release notes.
2001
+ - PR de release mergeada: `#610` (`commit acbdb73`).
2002
+ - publicación npm completada con éxito (`npm publish --access public`).
2003
+ - propagación validada: `latest=6.3.37`.
2004
+ - evidencia:
2005
+ - `npx --yes tsx@4.21.0 --test integrations/gate/__tests__/stagePolicies.test.ts integrations/git/__tests__/runPlatformGate.test.ts integrations/lifecycle/__tests__/status.test.ts integrations/lifecycle/__tests__/doctor.test.ts integrations/lifecycle/__tests__/cli.test.ts` => `81 passed, 0 failed`.
2006
+ - `npm run -s typecheck` => `exit 0`.
2007
+ - `npm view pumuki dist-tags --json` => `"latest": "6.3.37"`.
2008
+ - `npm view pumuki@6.3.37 version` => `6.3.37`.
2009
+
2010
+ - ✅ **Cierre consolidado del bloque P12.F2** (ID interno: `P12.F2.T74`).
2011
+ - cierre ejecutado:
2012
+ - bloque `P12.F2` consolidado con trazabilidad completa (issues/PRs/releases y evidencias de validación).
2013
+ - backlog siguiente preparado en modo standby, sin deuda técnica abierta obligatoria del bloque actual.
2014
+ - confirmada publicación estable `pumuki@6.3.37` como baseline para nuevos proyectos.
2015
+ - evidencia:
2016
+ - `gh issue view 606 --json state` => `CLOSED`.
2017
+ - `gh pr view 608 --json state,mergedAt` => `MERGED`.
2018
+ - `gh pr view 610 --json state,mergedAt` => `MERGED`.
2019
+ - `npm view pumuki dist-tags --json` => `"latest": "6.3.37"`.
2020
+
2021
+ - 🚧 **En espera de nueva orden** (ID interno: `P12.F2.T75`; sin deuda técnica pendiente de este bloque).
2022
+ - salida esperada:
2023
+ - mantener una sola `🚧` activa por política del tablero.
2024
+ - no ejecutar trabajo técnico nuevo hasta instrucción explícita del usuario.
1976
2025
 
1977
2026
  Criterio de salida F6:
1978
2027
  - veredicto final trazable y cierre administrativo completo.
@@ -89,7 +89,7 @@ export type RulesetState = {
89
89
  version?: string;
90
90
  signature?: string;
91
91
  source?: string;
92
- validation_status?: 'valid' | 'invalid' | 'expired' | 'unknown-source';
92
+ validation_status?: 'valid' | 'invalid' | 'expired' | 'unknown-source' | 'unsigned';
93
93
  validation_code?: string;
94
94
  degraded_mode_enabled?: boolean;
95
95
  degraded_mode_action?: 'allow' | 'block';
@@ -81,6 +81,12 @@ const DEFAULT_MAX_AGE_SECONDS: Readonly<Record<AiGateStage, number>> = {
81
81
  };
82
82
 
83
83
  const DEFAULT_PROTECTED_BRANCHES = new Set(['main', 'master', 'develop', 'dev']);
84
+ const MCP_RECEIPT_STAGE_ORDER: Readonly<Record<AiGateStage, number>> = {
85
+ PRE_WRITE: 0,
86
+ PRE_COMMIT: 1,
87
+ PRE_PUSH: 2,
88
+ CI: 3,
89
+ };
84
90
 
85
91
  const toErrorViolation = (code: string, message: string): AiGateViolation => ({
86
92
  code,
@@ -335,6 +341,13 @@ const toPolicyStage = (stage: AiGateStage): SkillsStage => {
335
341
  return stage;
336
342
  };
337
343
 
344
+ const isMcpReceiptStageCompatible = (params: {
345
+ receiptStage: AiGateStage;
346
+ requestedStage: AiGateStage;
347
+ }): boolean => {
348
+ return MCP_RECEIPT_STAGE_ORDER[params.receiptStage] >= MCP_RECEIPT_STAGE_ORDER[params.requestedStage];
349
+ };
350
+
338
351
  const collectMcpReceiptViolations = (params: {
339
352
  required: boolean;
340
353
  stage: AiGateStage;
@@ -405,11 +418,16 @@ const collectMcpReceiptViolations = (params: {
405
418
  )
406
419
  );
407
420
  }
408
- if (receiptRead.receipt.stage !== params.stage) {
421
+ if (
422
+ !isMcpReceiptStageCompatible({
423
+ receiptStage: receiptRead.receipt.stage,
424
+ requestedStage: params.stage,
425
+ })
426
+ ) {
409
427
  violations.push(
410
428
  toErrorViolation(
411
429
  'MCP_ENTERPRISE_RECEIPT_STAGE_MISMATCH',
412
- `MCP receipt stage mismatch (${receiptRead.receipt.stage} != ${params.stage}).`
430
+ `MCP receipt stage mismatch (${receiptRead.receipt.stage} incompatible with ${params.stage}).`
413
431
  )
414
432
  );
415
433
  }
@@ -49,9 +49,10 @@ export type ResolvedStagePolicy = {
49
49
  signature?: string;
50
50
  policySource?: string;
51
51
  validation?: {
52
- status: 'valid' | 'invalid' | 'expired' | 'unknown-source';
52
+ status: 'valid' | 'invalid' | 'expired' | 'unknown-source' | 'unsigned';
53
53
  code:
54
54
  | 'POLICY_AS_CODE_VALID'
55
+ | 'POLICY_AS_CODE_UNSIGNED'
55
56
  | 'POLICY_AS_CODE_CONTRACT_INVALID'
56
57
  | 'POLICY_AS_CODE_CONTRACT_EXPIRED'
57
58
  | 'POLICY_AS_CODE_SIGNATURE_MISMATCH'
@@ -307,6 +308,21 @@ const resolvePolicyAsCodeTraceMetadata = (params: {
307
308
  const contractPath = join(params.repoRoot, POLICY_AS_CODE_CONTRACT_PATH);
308
309
 
309
310
  if (!existsSync(contractPath)) {
311
+ if (strict) {
312
+ return {
313
+ version: computedVersion,
314
+ signature: computedSignature,
315
+ policySource: 'computed-local',
316
+ validation: {
317
+ status: 'unsigned',
318
+ code: 'POLICY_AS_CODE_UNSIGNED',
319
+ message:
320
+ 'Policy-as-code contract is missing; runtime policy metadata is unsigned.',
321
+ strict,
322
+ },
323
+ };
324
+ }
325
+
310
326
  return {
311
327
  version: computedVersion,
312
328
  signature: computedSignature,
@@ -2,6 +2,7 @@ import { execFileSync as runBinarySync } from 'node:child_process';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import type { Fact } from '../../core/facts/Fact';
5
+ import type { GitChange } from './gitDiffUtils';
5
6
  import { parseNameStatus, hasAllowedExtension, buildFactsFromChanges } from './gitDiffUtils';
6
7
  export { parseNameStatus } from './gitDiffUtils';
7
8
 
@@ -77,7 +78,16 @@ export class GitService implements IGitService {
77
78
  }
78
79
 
79
80
  getStagedAndUnstagedFacts(extensions: ReadonlyArray<string>): ReadonlyArray<Fact> {
80
- const trackedChanges = parseNameStatus(this.runGit(['diff', '--name-status', 'HEAD']))
81
+ const hasHeadCommit = this.hasHeadCommit();
82
+ const trackedNameStatus = hasHeadCommit
83
+ ? this.runGit(['diff', '--name-status', 'HEAD'])
84
+ : [
85
+ this.runGit(['diff', '--cached', '--name-status']),
86
+ this.runGit(['diff', '--name-status']),
87
+ ]
88
+ .filter((chunk) => chunk.trim().length > 0)
89
+ .join('\n');
90
+ const trackedChanges = this.deduplicateChangesByPath(parseNameStatus(trackedNameStatus))
81
91
  .filter((change) => hasAllowedExtension(change.path, extensions));
82
92
  const untrackedPaths = this.runGit(['ls-files', '--others', '--exclude-standard'])
83
93
  .split('\n')
@@ -100,6 +110,23 @@ export class GitService implements IGitService {
100
110
  );
101
111
  }
102
112
 
113
+ private hasHeadCommit(): boolean {
114
+ try {
115
+ this.runGit(['rev-parse', '--verify', 'HEAD']);
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ private deduplicateChangesByPath(changes: ReadonlyArray<GitChange>): ReadonlyArray<GitChange> {
123
+ const byPath = new Map<string, GitChange>();
124
+ for (const change of changes) {
125
+ byPath.set(change.path, change);
126
+ }
127
+ return Array.from(byPath.values());
128
+ }
129
+
103
130
  private readWorkingTreeFile(repoRoot: string, filePath: string): string {
104
131
  try {
105
132
  return readFileSync(join(repoRoot, filePath), 'utf8');
@@ -0,0 +1,274 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { GitService, type IGitService } from './GitService';
4
+
5
+ export type GitAtomicityStage = 'PRE_COMMIT' | 'PRE_PUSH' | 'CI';
6
+
7
+ export type GitAtomicityViolation = {
8
+ code:
9
+ | 'GIT_ATOMICITY_TOO_MANY_FILES'
10
+ | 'GIT_ATOMICITY_TOO_MANY_SCOPES'
11
+ | 'GIT_ATOMICITY_COMMIT_MESSAGE_TRACEABILITY';
12
+ message: string;
13
+ remediation: string;
14
+ };
15
+
16
+ export type GitAtomicityEvaluation = {
17
+ enabled: boolean;
18
+ allowed: boolean;
19
+ violations: ReadonlyArray<GitAtomicityViolation>;
20
+ };
21
+
22
+ type GitAtomicityConfig = {
23
+ enabled: boolean;
24
+ maxFiles: number;
25
+ maxScopes: number;
26
+ enforceCommitMessagePattern: boolean;
27
+ commitMessagePattern: string;
28
+ };
29
+
30
+ const ATOMICITY_CONFIG_FILE = '.pumuki/git-atomicity.json';
31
+ const DEFAULT_COMMIT_PATTERN =
32
+ '^(feat|fix|chore|refactor|docs|test|perf|build|ci|revert)(\\([^)]+\\))?:\\s.+$';
33
+
34
+ const defaultConfig: GitAtomicityConfig = {
35
+ enabled: false,
36
+ maxFiles: 25,
37
+ maxScopes: 2,
38
+ enforceCommitMessagePattern: true,
39
+ commitMessagePattern: DEFAULT_COMMIT_PATTERN,
40
+ };
41
+
42
+ const parseBooleanEnv = (value: string | undefined): boolean | undefined => {
43
+ if (typeof value !== 'string') {
44
+ return undefined;
45
+ }
46
+ const normalized = value.trim().toLowerCase();
47
+ if (normalized.length === 0) {
48
+ return undefined;
49
+ }
50
+ if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') {
51
+ return true;
52
+ }
53
+ if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') {
54
+ return false;
55
+ }
56
+ return undefined;
57
+ };
58
+
59
+ const parsePositiveIntegerEnv = (value: string | undefined): number | undefined => {
60
+ if (typeof value !== 'string') {
61
+ return undefined;
62
+ }
63
+ const parsed = Number.parseInt(value.trim(), 10);
64
+ if (!Number.isFinite(parsed) || parsed <= 0) {
65
+ return undefined;
66
+ }
67
+ return parsed;
68
+ };
69
+
70
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
71
+ typeof value === 'object' && value !== null;
72
+
73
+ const isNonEmptyString = (value: unknown): value is string =>
74
+ typeof value === 'string' && value.trim().length > 0;
75
+
76
+ const parseFileConfig = (repoRoot: string): Partial<GitAtomicityConfig> | undefined => {
77
+ const filePath = resolve(repoRoot, ATOMICITY_CONFIG_FILE);
78
+ if (!existsSync(filePath)) {
79
+ return undefined;
80
+ }
81
+ try {
82
+ const parsed: unknown = JSON.parse(readFileSync(filePath, 'utf8'));
83
+ if (!isRecord(parsed)) {
84
+ return undefined;
85
+ }
86
+ const fromFile: Partial<GitAtomicityConfig> = {};
87
+ if (typeof parsed.enabled === 'boolean') {
88
+ fromFile.enabled = parsed.enabled;
89
+ }
90
+ if (Number.isInteger(parsed.maxFiles) && Number(parsed.maxFiles) > 0) {
91
+ fromFile.maxFiles = Number(parsed.maxFiles);
92
+ }
93
+ if (Number.isInteger(parsed.maxScopes) && Number(parsed.maxScopes) > 0) {
94
+ fromFile.maxScopes = Number(parsed.maxScopes);
95
+ }
96
+ if (typeof parsed.enforceCommitMessagePattern === 'boolean') {
97
+ fromFile.enforceCommitMessagePattern = parsed.enforceCommitMessagePattern;
98
+ }
99
+ if (isNonEmptyString(parsed.commitMessagePattern)) {
100
+ fromFile.commitMessagePattern = parsed.commitMessagePattern;
101
+ }
102
+ return fromFile;
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ };
107
+
108
+ const resolveConfig = (repoRoot: string): GitAtomicityConfig => {
109
+ const fromFile = parseFileConfig(repoRoot) ?? {};
110
+
111
+ return {
112
+ enabled:
113
+ parseBooleanEnv(process.env.PUMUKI_GIT_ATOMICITY_ENABLED)
114
+ ?? fromFile.enabled
115
+ ?? defaultConfig.enabled,
116
+ maxFiles:
117
+ parsePositiveIntegerEnv(process.env.PUMUKI_GIT_ATOMICITY_MAX_FILES)
118
+ ?? fromFile.maxFiles
119
+ ?? defaultConfig.maxFiles,
120
+ maxScopes:
121
+ parsePositiveIntegerEnv(process.env.PUMUKI_GIT_ATOMICITY_MAX_SCOPES)
122
+ ?? fromFile.maxScopes
123
+ ?? defaultConfig.maxScopes,
124
+ enforceCommitMessagePattern:
125
+ parseBooleanEnv(process.env.PUMUKI_GIT_ATOMICITY_ENFORCE_COMMIT_PATTERN)
126
+ ?? fromFile.enforceCommitMessagePattern
127
+ ?? defaultConfig.enforceCommitMessagePattern,
128
+ commitMessagePattern:
129
+ process.env.PUMUKI_GIT_ATOMICITY_COMMIT_PATTERN?.trim()
130
+ || fromFile.commitMessagePattern
131
+ || defaultConfig.commitMessagePattern,
132
+ };
133
+ };
134
+
135
+ const parseLines = (value: string): ReadonlyArray<string> =>
136
+ value
137
+ .split('\n')
138
+ .map((line) => line.trim())
139
+ .filter((line) => line.length > 0);
140
+
141
+ const resolveScopeKey = (filePath: string): string => {
142
+ const normalized = filePath.replace(/\\/g, '/').trim();
143
+ const segments = normalized.split('/').filter((segment) => segment.length > 0);
144
+ if (segments.length === 0) {
145
+ return '';
146
+ }
147
+ if (
148
+ (segments[0] === 'apps' || segments[0] === 'packages' || segments[0] === 'services')
149
+ && segments.length >= 2
150
+ ) {
151
+ return `${segments[0]}/${segments[1]}`;
152
+ }
153
+ return segments[0] ?? '';
154
+ };
155
+
156
+ const collectChangedPaths = (params: {
157
+ git: IGitService;
158
+ repoRoot: string;
159
+ stage: GitAtomicityStage;
160
+ fromRef?: string;
161
+ toRef?: string;
162
+ }): ReadonlyArray<string> => {
163
+ if (params.stage === 'PRE_COMMIT') {
164
+ return parseLines(
165
+ params.git.runGit(['diff', '--cached', '--name-only', '--diff-filter=ACMR'], params.repoRoot)
166
+ );
167
+ }
168
+ if (!params.fromRef || !params.toRef) {
169
+ return [];
170
+ }
171
+ return parseLines(
172
+ params.git.runGit(
173
+ ['diff', '--name-only', '--diff-filter=ACMR', `${params.fromRef}..${params.toRef}`],
174
+ params.repoRoot
175
+ )
176
+ );
177
+ };
178
+
179
+ const collectCommitSubjects = (params: {
180
+ git: IGitService;
181
+ repoRoot: string;
182
+ fromRef?: string;
183
+ toRef?: string;
184
+ }): ReadonlyArray<string> => {
185
+ if (!params.fromRef || !params.toRef) {
186
+ return [];
187
+ }
188
+ return parseLines(
189
+ params.git.runGit(['log', '--format=%s', `${params.fromRef}..${params.toRef}`], params.repoRoot)
190
+ );
191
+ };
192
+
193
+ export const evaluateGitAtomicity = (params: {
194
+ git?: IGitService;
195
+ repoRoot?: string;
196
+ stage: GitAtomicityStage;
197
+ fromRef?: string;
198
+ toRef?: string;
199
+ }): GitAtomicityEvaluation => {
200
+ const git = params.git ?? new GitService();
201
+ const repoRoot = params.repoRoot ? resolve(params.repoRoot) : git.resolveRepoRoot();
202
+ const config = resolveConfig(repoRoot);
203
+ if (!config.enabled) {
204
+ return {
205
+ enabled: false,
206
+ allowed: true,
207
+ violations: [],
208
+ };
209
+ }
210
+
211
+ const violations: GitAtomicityViolation[] = [];
212
+ const changedPaths = collectChangedPaths({
213
+ git,
214
+ repoRoot,
215
+ stage: params.stage,
216
+ fromRef: params.fromRef,
217
+ toRef: params.toRef,
218
+ });
219
+
220
+ if (changedPaths.length > config.maxFiles) {
221
+ violations.push({
222
+ code: 'GIT_ATOMICITY_TOO_MANY_FILES',
223
+ message:
224
+ `Git atomicity guard blocked at ${params.stage}: changed_files=${changedPaths.length} exceeds max_files=${config.maxFiles}.`,
225
+ remediation: `Divide los cambios en commits más pequeños (máximo ${config.maxFiles} archivos por commit).`,
226
+ });
227
+ }
228
+
229
+ const scopeKeys = new Set(
230
+ changedPaths
231
+ .map((filePath) => resolveScopeKey(filePath))
232
+ .filter((scopeKey) => scopeKey.length > 0)
233
+ );
234
+ if (scopeKeys.size > config.maxScopes) {
235
+ violations.push({
236
+ code: 'GIT_ATOMICITY_TOO_MANY_SCOPES',
237
+ message:
238
+ `Git atomicity guard blocked at ${params.stage}: changed_scopes=${scopeKeys.size} exceeds max_scopes=${config.maxScopes}.`,
239
+ remediation: `Agrupa cambios por ámbito funcional (máximo ${config.maxScopes} scopes por commit).`,
240
+ });
241
+ }
242
+
243
+ if (config.enforceCommitMessagePattern && params.stage !== 'PRE_COMMIT') {
244
+ let pattern: RegExp;
245
+ try {
246
+ pattern = new RegExp(config.commitMessagePattern);
247
+ } catch {
248
+ pattern = new RegExp(DEFAULT_COMMIT_PATTERN);
249
+ }
250
+ const subjects = collectCommitSubjects({
251
+ git,
252
+ repoRoot,
253
+ fromRef: params.fromRef,
254
+ toRef: params.toRef,
255
+ });
256
+ const invalidSubjects = subjects.filter((subject) => !pattern.test(subject));
257
+ if (invalidSubjects.length > 0) {
258
+ const sample = invalidSubjects.slice(0, 3).join(' | ');
259
+ violations.push({
260
+ code: 'GIT_ATOMICITY_COMMIT_MESSAGE_TRACEABILITY',
261
+ message:
262
+ `Git atomicity guard blocked at ${params.stage}: commit messages without traceable pattern detected (${invalidSubjects.length}). sample=[${sample}].`,
263
+ remediation:
264
+ 'Reescribe los commits para cumplir el patrón de trazabilidad configurado (por ejemplo Conventional Commits).',
265
+ });
266
+ }
267
+ }
268
+
269
+ return {
270
+ enabled: true,
271
+ allowed: violations.length === 0,
272
+ violations,
273
+ };
274
+ };
@@ -132,6 +132,13 @@ const defaultDependencies: GateDependencies = {
132
132
  };
133
133
 
134
134
  const resolveCurrentBranch = (git: IGitService, repoRoot: string): string | null => {
135
+ try {
136
+ const symbolicBranch = git.runGit(['symbolic-ref', '--short', 'HEAD'], repoRoot).trim();
137
+ if (symbolicBranch.length > 0) {
138
+ return symbolicBranch;
139
+ }
140
+ } catch {
141
+ }
135
142
  try {
136
143
  const branch = git.runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot).trim();
137
144
  if (branch.length === 0 || branch === 'HEAD') {